#160

Bump seroval from 1.6.7 to 1.7.6 (GHSA-wp97-x4hv-qvhp) [SECURITY]

Opensuperbot-helperwants to merge intofrom

package-lock.json resolves one copy of seroval — 1.6.7 at package-lock.json:6657-6665 — reachable from the root through @tanstack/router-core@1.171.32 ("seroval": "^1.6.2", package-lock.json:3625-3633) and @tanstack/start-client-core@1.170.32 ("seroval": "^1.6.2"). 1.6.7 is the top of the affected range of GHSA-wp97-x4hv-qvhp / CVE-2026-116584, a denial of service in fromJSON/fromCrossJSON where a JSON object standing in for an array node's element list sets the length the decoder loops over. This PR moves that single package entry to 1.7.6, the newest release that contains the fix. No manifest changes: ^1.6.2 already admits 1.7.6.

Where the decoder is reached — the request path, in this repository's own code

Every TanStack Start server function is an HTTP route, and this app re-bases that route to /_server in its own Vite config:

  • vite.config.ts:42 — tanstackStart({ serverFns: { base: "/_server" } }). The route is POST /_server/<serverFnId>; /_server is this repository's own choice, not the framework default (/_serverFn, @tanstack/start-plugin-core/dist/esm/schema.js:164).
  • src/http/dispatch.ts:4 — return ["/app", "/login", "/auth", "/api/auth", "/_server"].some(...). This is the prefix test that claims the request for the Start handler.
  • src/server.ts:21 — const start = createStartHandler(defaultStreamHandler);
  • src/server.ts:34-35 — if (isAppRequest(request)) { const response = await start(request); ... }. A /_server/* request enters createStartHandler here.
  • src/start.ts:5-7 — createCsrfMiddleware({ filter: (context) => context.handlerType === "serverFn" }), the only middleware that runs before the handler. It compares the client-supplied Sec-Fetch-Site header (default same-origin) or Origin against the request origin; any non-browser client sets either one. It is not authentication.
  • src/start.ts:10-12 — authkitMiddleware() is added only when the WorkOS environment variables are set, and it calls args.next() unconditionally after refreshing the session cookie (@workos/authkit-tanstack-react-start@0.11.1/dist/server/middleware-body.js:12), so it does not gate the request either.
  • src/features/billing/billing.functions.ts:27 — getBillingLink = createServerFn({ method: "POST" }), one of the POST server functions that makes /_server a live route (the others are src/features/billing/billing.functions.ts:57 and :80, src/features/organizations/organizations.functions.ts:22, src/features/dashboard/dashboard.functions.ts:57). The .validator(...) at line 28 runs after the body has already been deserialized.

The library path from there, at the versions this lock installs:

  • node_modules/@tanstack/start-server-core/dist/esm/createStartHandler.js:404 — executeMiddleware(flattenedRequestMiddlewares.map(...), terminal, ...); the middlewares above run, then the terminal.
  • node_modules/@tanstack/start-server-core/dist/esm/createStartHandler.js:344 — the terminal calls handleServerAction({ request, serverFnId }).
  • node_modules/@tanstack/start-server-core/dist/esm/server-functions-handler.js:71 — const payload = contentType?.includes("application/json") ? fromJSON(await request.json(), { plugins: serovalPlugins }) : {}. This is the line that hands request-controlled JSON to seroval.fromJSON.
  • node_modules/seroval/dist/index.js:1311-1320 — deserializeArray: const items = node.a; const len = items.length; const result = assignIndexedValue$1(ctx, node.i, new Array(len)); for (let i = 0; ...; i < len; i++). node.a is read as any JSON value; nothing checks that it is an array, so {"length": 4294967295} fixes a loop count of 4.29 billion for a 51-byte body.

So the (route, request) pair is: POST /_server/<serverFnId>, Content-Type: application/json, with no credential of any kind. The server-function id is public — it is emitted into the client bundle that every visitor downloads.

Which advisory this claims, and which it does not

This packet claims GHSA-wp97-x4hv-qvhp / CVE-2026-116584 (fromJSON/fromCrossJSON denial of service via an unvalidated array-node length), and nothing else. The other two advisories published the same day against seroval <= 1.6.7 were checked and are not claimed:

  • GHSA-7rv5-mrxv-xcr9 / CVE-2026-116580 (deserialization invokes attacker-chosen callables) needs a plugin whose deserialized value carries an on method. This app registers exactly what the framework passes: getSerovalPlugins(defaultSerovalDeserializerPlugins) at server-functions-handler.js:39, i.e. tss/RawStream → a ReadableStream, $TSR/Error (ShallowErrorPlugin) → an Error, the serialize-only tss/RawStream half, and seroval/plugins/web/ReadableStream → a ReadableStream. None of those returns a function or an object with on. I did reproduce the type confusion itself: a 171-byte payload whose async-iterator node names a stream id and then hands that id to the $TSR/Error plugin is accepted by 1.6.7 and invoking the returned value throws TypeError: stream.on is not a function; 1.7.6 rejects it with SerovalMalformedNodeError. That is the defect being fixed, but in this repository it reaches no callable, so the advisory's impact does not apply and this PR does not claim it.
  • GHSA-97r2-qh9m-474h / CVE-2026-116581 (array length and sequence doneAt) is a strict subset of the same fix and is subsumed by the same version move; it is not separately claimed either.

Version note, stated precisely: the advisory's first_patched_version is 1.6.8, not 1.7.6. Measured directly: 1.6.7 accepts the crafted payload (260 ms at a declared length of 200,000,000, a 50-byte body), 1.6.8 rejects it in 0.2 ms with SerovalMalformedNodeError, and 1.7.5 and 1.7.6 reject it identically. 1.7.6 was published 2026-10-31T11:18:21Z and is npm's latest; its own change since 1.7.5 is an unrelated RegExp getter fix. This PR moves to 1.7.6 because that is the current release line this repository would install next, not because 1.7.6 is where the array-length fix landed.

What was executed

Node v26.13.0, npm 11.20.0 / 10.9.4, macOS 27.0.1 arm64. Two npm ci --ignore-scripts --no-audit --no-fund runs with npm 10.9.4 (the npm Node 22 ships, which is what .github/workflows/check.yml:21,26 uses) on evidence/base/package.json:

  • committed lock: exit 0, 287 packages, node_modules/seroval is 1.6.7;
  • patched lock: exit 0, 287 packages, node_modules/seroval is 1.7.6.

npm 11.20.0 refuses the committed lock (Missing: @kreuzberg/tree-sitter-language-pack-linux-arm64-musl@ from lock file) before this patch is applied; that is a property of the base lock, not of this change, and it is why the gate above ran with npm 10.9.4.

The measurement harness is a replica of the three lines that matter, run against the installed trees: an HTTP server that, for POST /_server/<id> with Content-Type: application/json, executes fromJSON(await request.json(), { plugins }) with defaultSerovalDeserializerPlugins imported from @tanstack/router-core/ssr/server — the same object the framework passes. A 10 ms timer in the server process records the longest gap between ticks, and a second connection sends GET /ping every 5 ms while the target request is in flight. One arm at a time, never two servers; three rounds per payload; medians below, ranges in the transcript.

Crafted payload (a is an object, not an array — 51 bytes on the wire):

{"t":{"t":9,"i":0,"a":{"length":4294967295},"o":0}}
declared lengthbody bytes1.6.7 (this lock today)1.7.51.7.6 (this PR)
1,000,000482.5 ms, loop stalled 10.3 ms, 2000.1 ms, 5000.1 ms, 500
10,000,0004924.6 ms, stalled 24.6 ms, 2000.1 ms, 5000.04 ms, 500
50,000,0004978.9 ms, stalled 79.0 ms, 2000.1 ms, 5000.05 ms, 500
100,000,00050153.8 ms, stalled 153.8 ms, 2000.1 ms, 5000.06 ms, 500
200,000,00050314.6 ms, stalled 314.7 ms, 2000.1 ms, 5000.06 ms, 500
500,000,000501,253.8 ms, stalled 1,253.9 ms, 2000.1 ms, 5000.06 ms, 500
4,294,967,2955128,706 ms (26,342–36,349), stalled 28,706 ms, 2000.06 ms, 5000.05 ms, 500
control: real array of 100,000 elements2,300,0318.8 ms, stalled 11.1 ms, 20010.3 ms, 20013.8 ms, 200

The growth exponent of handler time against the declared length is 1.08 on 1.6.7 (linear) and −0.04 on 1.7.6 (flat). The exponent against payload bytes is the point: the body is 48–51 bytes in every crafted row, so cost grows with a number the sender writes and not with anything the sender has to send. The worst row is a 51-byte request that blocks the server's event loop for 28.7 seconds and makes the concurrent GET /ping wait 36.4 s; the same 1.7.6 server answers that payload in 0.05 ms with SerovalDeserializationError / SerovalMalformedNodeError, and the loop never stalls past the 10 ms timer floor. The control row is the same decoder on honest input: 2.3 MB of real array elements costs 8.8 ms on 1.6.7 — the 51-byte crafted payload costs 3,281× that.

Limits of what was run. The repository's own Cloudflare Worker was not booted: it needs the WorkOS environment, D1/Hyperdrive bindings and a built client, and none of that is touched here. The harness reproduces the handler's JSON branch rather than importing handleServerAction, because @tanstack/start-client-core has no Node-resolvable entry and the server-function manifest is generated at build time. The plugin array is the router's four default deserializer plugins; the array node is decided before any plugin is consulted, so the plugin list does not change the measured path. No repository test suite was run against the upgraded lock; the install gates above are the executed proof.

What this does and does not change

  • It changes node_modules/seroval from 1.6.7 to 1.7.6: version, resolved, integrity, and engines (node >=10 → node >=20). seroval-plugins stays at 1.6.7; its peer range is seroval: ^1.0, which 1.7.6 satisfies.
  • The same npm install also writes six inBundle: true records under node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/ and a dev: true flag on node_modules/@emnapi/runtime. Those are npm's own bookkeeping for an optional wasm binding, absent from the committed lock; they add and move no installed package. They are in the diff because the predictor's npm install emits them, and the patch is the lock it resolved rather than a hand-trimmed subset of it.
  • It does not change package.json, any route, the _server base, the CSRF or AuthKit middleware, or any application code, and it does not move seroval's parents (@tanstack/router-core, @tanstack/start-client-core, @tanstack/react-start), whose ^1.6.2 range already admits 1.7.6.

Activity

3
coderabbitai[bot]10d ago

IMPORTANT

Review skipped

Review was skipped due to path filters

:no_entry: Files ignored due to path filters: 1
  • package-lock.json is excluded by !**/package-lock.json

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 58feae00-37f7-40df-b1e8-5e57fae73ea8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share