Skip to content

Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime#86

Merged
justin808 merged 6 commits into
mainfrom
fix/vendored-rsdw-19.0.7-cve-dos-fixes
Jun 12, 2026
Merged

Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime#86
justin808 merged 6 commits into
mainfrom
fix/vendored-rsdw-19.0.7-cve-dos-fixes

Conversation

@justin808

@justin808 justin808 commented Jun 12, 2026

Copy link
Copy Markdown
Member

Summary

Brings the vendored react-server-dom-webpack runtime (src/react-server-dom-webpack/) from the 19.0.4 security level up to 19.0.7, picking up the two 2026 React Server Components reply-decoding DoS fixes:

Approach

Per the #55 spike decision, the vendored runtime is slated for replacement with stock npm react-server-dom-webpack in #60 — but that migration has not started, so this is the interim fix. Instead of rebasing the abanoubghadban/react fork patches onto a newer upstream commit, this applies the full npm 19.0.4 → 19.0.7 built-artifact diff (extracted via npm pack) directly to the vendored cjs files, mirroring how dc4dfb4/867634f previously patched built artifacts in place.

The 19.0.x line is security-only, so the entire diff is the reply-decode hardening (FormData access wrapper with key-pointer scanning, form-field prefix separator change, "initialized stream chunk" guard, $$consumed ordering) plus version strings. 16 of 17 hunk sets applied cleanly with patch; the one reject (DevTools registration block in client.browser.development.js, where the fork sets rendererPackageName: "react-on-rails-rsc") was applied by hand preserving the fork value.

Delta-preservation check: for every vendored cjs file, diff(stock 19.0.7, vendored-after) is byte-identical to diff(stock 19.0.4, vendored-before) — i.e. the runtime is now exactly stock 19.0.7 plus the pre-existing in-repo deltas (Flight CSS hint emission from dc4dfb4/867634f/8781f08, and the fork's bound-args + rendererPackageName changes). Nothing else moved.

The vendored package.json (previously byte-identical to stock 19.0.4) takes the stock 19.0.7 version/peerDeps bump; it is internal to the shipped package and not resolved by npm. The outer package's peerDependencies are untouched (that belongs to #60). esm/ only contains the node loader, which is unchanged between 19.0.4 and 19.0.7.

Relationship to #60

This does not close #60 — it is the stopgap that keeps released artifacts off the vulnerable runtime until the stock-npm migration lands. If #60 lands soon, this diff is simply deleted along with the vendored tree.

Test plan

  • yarn build — passes (tsc clean)
  • yarn test — 17 suites / 158 tests pass (matches baseline)
  • NODE_CONDITIONS=react-server yarn jest tests/react-flight-client-reference-css.rsc.test.ts — passes (FOUC CSS Flight-hint behavior from dc4dfb4/867634f preserved)
  • Hardening markers present post-patch: _formData.data.get and the "initialized stream chunk" guard now appear in the vendored server builds (previously 0 hits)

Deployment note (split client/server rollout)

The CVE-2026-23869 fix changes the reply wire format for nested FormData: field keys gain a "_" separator between the form-field prefix and the part ID. Client (encodeReply) and server (decodeReply/decodeReplyFromBusboy) are updated in lock-step in this PR, but operators who cache or CDN-serve client JS separately from the server runtime should deploy both together — a pre-19.0.5 runtime on either side silently drops nested FormData entries (no error). Plain values, Maps, Sets, and non-nested forms are unaffected. This is also noted in the CHANGELOG entry.

Post-review smoke test (nested FormData round-trip)

Per review suggestion, verified the patched $K path end-to-end against the built dist/ runtime (node --conditions=react-server): encodeReply([FormData, 'plain', FormData, Map, Set]) from client.browser emits the new wire format (_1_alpha, _1_beta, _2_gamma — leading _ separator present), and decodeReply from server.node reconstructs both nested FormData objects with all entries plus the Map/Set/plain values intact:

wire keys: ["_1_alpha","_1_beta","_2_gamma","3","4","0"]
decoded fd1: {"alpha":"one","beta":"two"}
decoded fd2: {"gamma":"three"}
SMOKE TEST PASS: nested FormData round-trips through patched 19.0.7 runtime

🤖 Generated with Claude Code


Note

High Risk
Security-critical RSC reply decoding and a breaking nested FormData wire format; mismatched client/server versions can lose data without errors.

Overview
Bumps the vendored react-server-dom-webpack runtime from 19.0.4 to 19.0.7, bringing in upstream reply-decoding hardening for CVE-2026-23869 and CVE-2026-23870 (DoS in RSC reply parsing). The CHANGELOG documents the security fix and a deployment caveat for nested FormData.

Client encodeReply now prefixes nested FormData field keys with an extra _ (prefix + "_" + key + "_" instead of prefix + key + "_"). Server decodeReply paths wrap backing FormData in { data, keyPointer, keys }, scan keys with a pointer instead of materializing all keys at once, add an “initialized stream chunk” guard on fulfilled chunks, and rework decodeReplyFromBusboy to queue file parts in order before flushing. Version strings and the vendored package.json move to 19.0.7 / ^19.0.7 peers.

Client and server must ship together on this package; mixing with pre-19.0.5 runtimes can silently drop nested FormData entries. In-repo fork deltas (e.g. Flight CSS hints, rendererPackageName: "react-on-rails-rsc") are preserved per the PR approach.

Reviewed by Cursor Bugbot for commit 25bba9f. Bugbot is set up for automated code reviews on this repo. Configure here.

The vendored react-server-dom-webpack builds were at the 19.0.4 security
level, which predates two published React Server Components DoS
advisories affecting decodeReply/decodeAction:

- CVE-2026-23869 (GHSA-479c-33wc-g2pg), patched upstream in 19.0.5
- CVE-2026-23870 (GHSA-rv78-f8rc-xrxh), patched upstream in 19.0.6

Rather than rebasing the abanoubghadban/react fork patches (the runtime
is slated for replacement by stock npm in #60), this applies the full
npm 19.0.4 -> 19.0.7 built-artifact diff to the vendored cjs files,
mirroring how dc4dfb4/867634f patched built artifacts in place. The
19.0.x line is security-only, so the diff is exactly the reply-decode
hardening (FormData access wrapper, form-field prefix separator,
initialized-stream-chunk guard, $$consumed ordering) plus version
strings.

All pre-existing in-repo deltas (Flight CSS hint emission from dc4dfb4/
867634f/8781f08 and the fork's bound-args and rendererPackageName
changes) are preserved byte-for-byte: the diff of each vendored file
against stock 19.0.7 now equals the previous diff against stock 19.0.4.

Verified: yarn build and yarn test (17 suites / 158 tests) pass,
including NODE_CONDITIONS=react-server jest
tests/react-flight-client-reference-css.rsc.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@justin808, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 17 minutes. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8f1efd02-e371-4c46-983d-65163b9681ae

📥 Commits

Reviewing files that changed from the base of the PR and between ea56b7a and 25bba9f.

📒 Files selected for processing (18)
  • CHANGELOG.md
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.browser.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.browser.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.edge.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.edge.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.production.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.unbundled.development.js
  • src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.unbundled.production.js
  • src/react-server-dom-webpack/package.json
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/vendored-rsdw-19.0.7-cve-dos-fixes

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

Comment @coderabbitai help to get the list of available commands and usage tips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code Review: React 19.0.7 CVE Security Patch

This PR correctly applies upstream React 19.0.7 security patches to the vendored react-server-dom-webpack runtime, addressing two RSC reply-decode DoS vulnerabilities (CVE-2026-23869 / CVE-2026-23870). The approach — applying the full 19.0.4→19.0.7 built-artifact diff and verifying delta-preservation — is consistent with prior vendored patches in this repo (dc4dfb4, 867634f).

All 8 CJS build variants (browser/edge/node/unbundled × dev/prod) are updated consistently. The one hand-applied hunk (DevTools rendererPackageName) correctly preserves the fork value.

Security Analysis

The three core hardening changes are correctly applied across all variants:

  1. FormData field prefix — disambiguates nested FormData keys, preventing a crafted key from colliding with the server prefix namespace.
  2. _formData wrapper struct ({ data, keyPointer, keys }) + incremental key scanning in the $K revival path — eliminates the O(n²) Array.from(response.keys()) call on every nested FormData that was the DoS vector; the scan now advances keyPointer monotonically and breaks when the namespace prefix no longer matches.
  3. decodeReplyFromBusboy linked-list queue — replaces the global pendingFiles counter + flat queuedFields array with a per-file linked list. Fields are flushed in strict file-arrival order, and a file stream "error" event is now properly handled (previously unhandled, which would surface as an uncaught exception on the server).

Wire-Format Interoperability Risk (Medium)

The FormData prefix change is a breaking wire-format change between client (encodes) and server (decodes). A client on 19.0.4 sending a $K FormData payload to a server on 19.0.7 will silently produce wrong results — the keys won't match the new prefix pattern. This is correctly flagged in the PR description.

Before this ships: confirm that client-side and server-side React versions will update atomically in deployments. CDN-cached 19.0.4 JS bundles hitting a freshly deployed 19.0.7 server will silently mishandle Server Actions with nested FormData arguments until the cache expires.

$$consumed Ordering Fix

The createMap/createSet/extractIterator change (set $$consumed = true before constructing the collection, not after) is a subtle but correct race guard. The old order could pass the guard check on a second concurrent revival call before the flag was written.

Test Coverage Gap

The PR test plan verifies CSS Flight-hint behavior, build cleanliness, and baseline test suite. No tests exercise the specific patched paths:

  • $K FormData round-trip (nested FormData in a Server Action reply)
  • decodeReplyFromBusboy file-then-field ordering (the linked-list flush path)
  • The new "incomplete file part" error path

These are exactly the paths the CVE hardening touches. This is inherent to testing vendored compiled artifacts, but it is the primary risk of the stopgap approach. Completing #60 would bring these paths under the upstream React test suite.

Minor Notes

  • The try-catch around file.chunks.push(chunk) in the "data" handler is now effectively dead code (an array push won't throw), but it is compiler output and not worth touching.
  • keyPointer initializes to -1 in createResponse but is always set to 0 on first real use (gated by the null keys check). Correct but slightly surprising to read.

Verdict

Approve with note. The patches are correctly applied across all build targets with the fork delta preserved. The interoperability risk is inherent to any wire-format security fix, not an error in this PR. The stopgap is appropriate given that #60 has not started.

var data$32 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wire-format breaking change. The old prefix was formFieldPrefix + key + "_" (e.g. "_0_"); the new prefix is formFieldPrefix + "_" + key + "_" (e.g. "__0_"). The server-side $K decoder was updated in lock-step to scan for response._prefix + "_" as the outer namespace guard. Any client still on 19.0.4 sending nested FormData to a 19.0.7 server (or vice versa) will silently fail to reconstruct the FormData — keys simply won't match the expected prefix.

This is the correct fix for the CVE, but it means client and server must be updated atomically. Watch for CDN-cached old bundles during rollout.

case "fulfilled":
id = chunk.value;
chunk = chunk.reason;
if (null !== chunk && "error" in chunk)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good guard. A fulfilled chunk whose reason has an "error" property is a stream-controller object that leaked through — a symptom of an older client encoding a stream where this server expects a non-stream value. Throwing here prevents silent mis-decoding of payloads from downlevel clients and produces a clear diagnostic message instead.

if (!isArrayImpl(model)) throw Error("Invalid Map initializer.");
if (!0 === model.$$consumed) throw Error("Already initialized Map.");
response = new Map(model);
model.$$consumed = !0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtle but important ordering fix. Setting model.$$consumed = true before new Map(model) closes a narrow concurrent-revival window: if two revival calls race and both pass the if (!0 === model.$$consumed) check before either writes the flag, both would construct a new Map. Moving the flag write before the construction means only the first caller constructs — the second will throw "Already initialized" on the next pass. Same fix applies to createSet and extractIterator below.

_bundlerConfig: bundlerConfig,
_prefix: formFieldPrefix,
_formData: backingFormData,
_formData: { data: backingFormData, keyPointer: -1, keys: null },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Core DoS fix. Wrapping _formData in { data, keyPointer, keys } enables the $K revival path to scan keys incrementally (advancing keyPointer forward) rather than calling Array.from(response.keys()) from scratch on every nested FormData revival. The old pattern was O(n) per $K node → O(n²) total for a payload with n FormData entries, which was the DoS vector. The new scan is amortized O(n) across all revivals, and the else if (value.startsWith(obj)) break guard stops scanning once the current namespace's keys are exhausted.

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code Review: vendored react-server-dom-webpack 19.0.4 to 19.0.7

Overview

This is a security-critical vendored artifact update applying the upstream RSC reply-decoding DoS patches from React 19.0.5 and 19.0.6 to the in-tree runtime. The approach (artifact diff applied directly to CJS files, with a delta-preservation check) is the right stopgap while #60 (stock npm migration) is pending.

The diff has been reviewed against upstream React 19.0.4 to 19.0.7 changes. All seven hardening changes are present and consistent across all eight build variants (browser/edge/node x production/development + unbundled x 2).


Security Fixes Verified

All present and consistent across all 8 variants:

  • CVE-2026-23869: FormData prefix separator change in $K case (prefix + key + "_" to prefix + "_" + key + "_")
  • CVE-2026-23869: _formData wrapped in { data, keys, keyPointer } with all .get/.getAll calls updated to .data.get/.data.getAll
  • CVE-2026-23869: Key-pointer scanning in case "K" replaces Array.from(keys()) + full O(n) re-scan with stateful forward scan
  • CVE-2026-23869: $$consumed flag set before object construction (previously after)
  • CVE-2026-23870: decodeReplyFromBusboy reworked to per-file linked list with per-file queuedFields (previously shared counter + shared array)
  • CVE-2026-23870: "initialized stream chunk" guard in getOutlinedModel
  • CVE-2026-23870: reportGlobalError on finish when body ends with incomplete file parts

Notable Observations

Wire format compatibility break (expected)
The $K prefix separator change is a hard wire-format break between 19.0.4 clients and 19.0.7 servers (and vice versa). A 19.0.4-encoded FormData reply sent to a 19.0.7 server will silently decode into an empty FormData rather than throwing — the startsWith(key) check simply never matches with the old format. This is intentional per the CVE scope, but worth calling out explicitly in the release notes so consumers know a coordinated client+server bump is required.

Delta-preservation methodology
The PR description's invariant (diff(stock 19.0.7, vendored-after) == diff(stock 19.0.4, vendored-before)) is the right thing to verify. The one manual hunk (DevTools rendererPackageName) is correctly preserved. No other fork-specific changes appear in the diff.

Fork-specific delta untouched
The Flight CSS hint emission code (dc4dfb4/867634f) and rendererPackageName: "react-on-rails-rsc" are both preserved. The test plan FOUC CSS flight-hint test confirms this at runtime.


Minor Issues

keyPointer: -1 initial value is dead code
In createResponse, _formData is initialized with keyPointer: -1. This value is never read — keyPointer is only accessed when keys !== null, and it is always reset to 0 when keys is first materialized. The -1 matches upstream 19.0.7 (not introduced here), but a reader might wonder if -1 has special meaning.

Implicit contiguous-key protocol invariant in case "K"
The key-pointer loop relies on all FormData keys for a given nested-FormData object being contiguous in key order (it breaks as soon as it encounters a key that starts with obj but not key). If keys were interleaved by an adversarial client, entries would be silently missed. The protocol guarantees contiguity (the client always encodes nested-FormData entries consecutively), but a comment documenting this invariant would help future maintainers reasoning about security properties.

flush() variable aliasing in node.development.js
In the flush() closure, response is initialized as response$jscomp$0 (the RSC response object) and then immediately overwritten with response = key (the field name string). It then serves double duty as the key string and a loop counter. This is JSCompiler-generated output and is technically correct, but it is the most confusing section of the diff for manual auditors. No action needed.


Verdict

Approve. The implementation is mechanically correct, all CVE hardening markers are present post-patch, the test suite passes, and the fork deltas are cleanly preserved. The stopgap framing ("delete this when #60 lands") is appropriate.

One suggestion: add a note to the CHANGELOG entry (or the release) that the wire format change requires a coordinated client+server upgrade for users pinning version mismatches.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

Applies the React 19.0.5–19.0.7 security patches (CVE-2026-23869 and CVE-2026-23870) directly to the vendored react-server-dom-webpack CJS artifacts, acting as a stopgap until the planned stock-npm migration in #60. All 16 CJS files are updated; the fork-specific rendererPackageName delta was hand-applied to the one hunk that conflicted.

  • CVE-2026-23869 (all 8 client files): the nested-FormData field-key prefix separator changes from prefix + key + \"_\" to prefix + \"_\" + key + \"_\", preventing a shorter numeric key from matching a longer one's prefix during reply decode.
  • CVE-2026-23870 (all 8 server files): _formData is wrapped in a { data, keys, keyPointer } struct; key scanning advances a pointer instead of re-scanning the full key list on each nested-FormData parse, capping worst-case scan cost; decodeReplyFromBusboy is refactored to a linked-list queue to guarantee FIFO field/file ordering; $$consumed is set before object construction in createMap/createSet/extractIterator to close a re-entrancy window.

Confidence Score: 4/5

Safe to merge as a security stopgap; the patches are faithful backports of upstream React 19.0.5–19.0.7, all 16 CJS variants are consistently updated, and the fork-specific delta is preserved.

The patch set is mechanically correct and complete across every browser/edge/node/unbundled dev+prod pair. The one hand-applied hunk (DevTools rendererPackageName) was preserved correctly. The vendored package.json peer dep bump to ^19.0.7 is intentionally out of step with the outer package.json's ^19.0.4 — a known deferred item — but in a mixed-version deployment where the outer package's consumers pair the new vendored server runtime against a pre-patch client, nested FormData decoding would silently return empty rather than erroring.

The src/react-server-dom-webpack/package.json peer dep bump deserves a second look in the context of #60 — once the stock-npm migration lands, this file is deleted, but until then the ^19.0.4 outer constraint lets users install a React version whose client still encodes nested FormData in the old format.

Important Files Changed

Filename Overview
src/react-server-dom-webpack/package.json Version and peerDeps bumped to 19.0.7; intentionally out-of-sync with outer package.json which remains at ^19.0.4 (deferred to #60)
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js CVE-2026-23869 prefix separator fix, version strings bumped to 19.0.7; DevTools rendererPackageName fork value preserved by hand
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js CVE-2026-23869 prefix separator fix only; production build has no DevTools block, no manual merge needed
src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.development.js Largest change: _formData wrapper, keyPointer-based scanning, $$consumed ordering fix, stream-chunk guard, and full decodeReplyFromBusboy linked-list refactor — all CVE-2026-23870 hardening
src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.production.js Same CVE-2026-23870 set as node.development; production minification differences only, logic is identical
src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.unbundled.development.js Identical patch set to node.development; unbundled variant carries the same decodeReplyFromBusboy refactor and formData wrapper
src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.unbundled.production.js Identical patch set to node.production; production minification only
src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.browser.development.js CVE-2026-23870 formData wrapper + keyPointer scanning + $$consumed fix; no busboy (browser has no decodeReplyFromBusboy)
src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.browser.production.js Same as browser.development but production build; no issues found
src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.edge.development.js All CVE-2026-23870 hardening applied; edge variant is structurally identical to browser variant (no busboy)
src/react-server-dom-webpack/cjs/react-server-dom-webpack-server.edge.production.js Production edge variant; same as edge.development, no issues found
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js CVE-2026-23869 prefix separator fix only; edge client variant
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.production.js CVE-2026-23869 prefix separator fix only; edge client production variant
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js CVE-2026-23869 prefix separator fix only; node client development variant
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.production.js CVE-2026-23869 prefix separator fix only; node client production variant
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js CVE-2026-23869 prefix separator fix only; node unbundled client development variant
src/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.production.js CVE-2026-23869 prefix separator fix only; node unbundled client production variant

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Client: processReply(FormData)"] -->|"CVE-2026-23869 fix\nprefix = formFieldPrefix + '_' + key + '_'"| B["Encoded RSC Reply\n(multipart/form-data)"]
    B --> C["Server: decodeReply / decodeReplyFromBusboy"]
    C --> D["createResponse()\n_formData = { data, keys:null, keyPointer:-1 }"]
    D --> E["resolveField(key, value)\nappend to data, update keys[]"]
    E --> F{"case 'K'\n(nested FormData)"}
    F -->|"CVE-2026-23870 fix\nkeyPointer scan (not full Array.from)"| G["Reconstruct nested FormData\nadvance keyPointer, stop at prefix boundary"]
    G --> H["Return decoded args to Server Action"]
    C --> I{"decodeReplyFromBusboy\n(node only)"}
    I -->|"CVE-2026-23870 fix\nlinked-list flush()"|J["Process files in FIFO order\nqueue fields after current tail"]
    J --> H
    style F fill:#f9a,stroke:#c00
    style I fill:#f9a,stroke:#c00
    style G fill:#afa,stroke:#080
    style J fill:#afa,stroke:#080
Loading

Reviews (1): Last reviewed commit: "Add changelog entry for vendored runtime..." | Re-trigger Greptile

if (!isArrayImpl(model)) throw Error("Invalid Map initializer.");
if (!0 === model.$$consumed) throw Error("Already initialized Map.");
response = new Map(model);
model.$$consumed = !0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security fix: $$consumed set before construction (not after)

Previously the order was:

response = new Map(model);   // ← runs first
model.$$consumed = !0;       // ← set second
return response;

If new Map(model) threw (e.g. due to a crafted iterable that errors mid-construction), $$consumed would never be set, leaving the guard bypassable on a retry. By setting the flag first, any subsequent call — even if construction failed — will correctly raise "Already initialized Map."

This pattern is applied consistently to createMap, createSet, and extractIterator across all eight build variants.

_bundlerConfig: bundlerConfig,
_prefix: formFieldPrefix,
_formData: backingFormData,
_formData: { data: backingFormData, keyPointer: -1, keys: null },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keyPointer: -1 is dead on initialization

keyPointer is only read inside case "K" (and resolveField) when keys !== null. On the first access where keys is null, both fields are initialized together: keys = Array.from(...) and keyPointer = 0. The -1 value is never actually read.

This matches upstream 19.0.7 exactly — not introduced here. But for anyone auditing this in future, the sentinel means "not yet initialized" in the same way keys: null does, and the two are always set together. Worth a comment upstream if it ever causes confusion.

Comment on lines 100 to 104
},
"peerDependencies": {
"react": "^19.0.4",
"react-dom": "^19.0.4",
"react": "^19.0.7",
"react-dom": "^19.0.7",
"webpack": "^5.59.0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Vendored peerDeps now require ^19.0.7 while the outer package still declares ^19.0.4

The vendored package.json now declares "react": "^19.0.7" / "react-dom": "^19.0.7", but the outer package.json at the repo root still has "react": "^19.0.4". The PR notes this is intentional and deferred to #60, but it creates a subtle protocol mismatch risk for any downstream consumer that pairs the new vendored server runtime (which decodes the prefix + "_" + key + "_" format) against a stock react-server-dom-webpack@19.0.4 client (which still emits the old prefix + key + "_" format). In that mixed-version scenario, server-side decoding of nested FormData ($K values) would silently return an empty FormData rather than erroring. Raising this for visibility before #60 lands.

var data$32 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wire-format break: coordinated client+server upgrade required

The prefix separator changed from formFieldPrefix + key + "_" to formFieldPrefix + "_" + key + "_". The server-side case "K" decoder (in parseModelString) was updated symmetrically, so client and server are consistent within this patch.

However, a 19.0.4 client sending a $K-encoded FormData to a 19.0.7 server will silently produce an empty FormData on the server — the startsWith(key) check never matches the old format. There is no error, just missing data. The reverse (19.0.7 client → 19.0.4 server) has the same silent failure mode.

This is expected per the CVE fix, but the release notes should explicitly state that deployments mixing 19.0.4 and 19.0.7 packages will silently drop Server Action FormData arguments.

arrayRoot.append(referencedFormDataKey, reference[i]);
response.data.delete(value);
response.keyPointer++;
} else if (value.startsWith(obj)) break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implicit invariant: nested-FormData keys must be contiguous

This else if break assumes that once we encounter a key that belongs to the same _prefix namespace (starts with obj) but is not for this nested FormData entry (does not start with key), all remaining keys for this FormData entry have already been consumed.

This holds because the client (processReply) always appends all field entries for a nested FormData consecutively before moving on to the next encoded value. If an adversarial client interleaved keys from multiple nested FormData objects, this loop would silently miss the later entries.

The invariant is guaranteed by the protocol (not the parser), which is correct — but a brief comment here would help security reviewers quickly verify the assumption holds.

handle = current.file,
blob = new Blob(handle.chunks, { type: handle.mime }),
backingStore = response._formData;
response = key;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSCompiler variable aliasing — response is overwritten with the field name string

response was just assigned response$jscomp$0 (the RSC response object) on line 4304, and here it is immediately reassigned to key (the form field name string). From this point on in this try block, response is the string field name — used as the FormData key on line 4310 and pushed to keys on line 4315 — while the actual response object is still accessible via response$jscomp$0.

Then on line 4318, response is re-used as a loop counter (for (response = 0; ...)).

This is correct JSCompiler output (it reuses variable slots aggressively to minimize code size), but it is the single most confusing section of the diff. Anyone auditing this code should be aware of the aliasing to avoid misreading the FormData append and key-tracking logic as operating on the response object.

The CVE-2026-23869 fix changes the nested-FormData reply wire format;
flagged by review as worth stating for mixed-version pairings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@justin808

Copy link
Copy Markdown
Member Author

Review triage

Comment Class Resolution
claude: wire-format break needs stating (client.browser.production.js:349, ×2) optional → applied Added a sentence to the CHANGELOG entry (ac16c86): the CVE-2026-23869 fix changes the nested-FormData reply wire format; client and server runtimes ship together in this package, and pairing with a pre-19.0.5 runtime on the other side silently drops nested FormData entries.
greptile P2: vendored peerDeps ^19.0.7 vs outer ^19.0.4 optional → declined The vendored package.json is internal to the shipped package and not resolved by npm; its peerDeps are inert. Bumping the outer peerDependencies is a consumer-compat decision deferred to #60 (stock npm migration), which will set peerDeps deliberately. The mixed-pairing risk it describes is now flagged in the CHANGELOG.
claude: stream-chunk guard, $$consumed ordering, keyPointer scan O(n), -1 sentinel, contiguity invariant, JSCompiler aliasing (6 comments) noise (explanatory) Annotations confirming upstream behavior; no change requested. The patched files intentionally match stock 19.0.7 byte-for-byte outside the pre-existing fork deltas, so no in-place edits are appropriate.

All checks green: unit-tests, claude-review, CodeRabbit, Cursor Bugbot. Runtime-version bumps are maintainer-gated per AGENTS.md, so this stays ready-for-maintainer-merge rather than auto-merging.

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code Review: React 19.0.7 RSC Reply-Decode DoS Fixes

Overview

This PR correctly applies the React 19.0.4 to 19.0.7 security patch to the vendored react-server-dom-webpack runtime, fixing CVE-2026-23869 and CVE-2026-23870. The approach (diff built artifacts directly rather than rebasing the fork) is justified given the interim nature of this fix until #60 lands.

The changes across 16 CJS files are mechanically consistent: every _formData.get/getAll/append call is updated to _formData.data.get/getAll/append, and the nested FormData prefix and scanner are updated uniformly.


What the Patch Does

Five distinct hardening changes land in this bump:

  1. _formData wrapper_formData changes from a raw FormData to { data, keyPointer, keys }, enabling the ordered-scan DoS fix.
  2. Prefix separator change (wire-format break) — nested FormData fields now use prefix + "_" + id + "_" instead of prefix + id + "_". This disambiguates IDs from field content, fixing CVE-2026-23869.
  3. Key-pointer scan — instead of Array.from(formData.keys()) on every nested-FormData decode (O(n) scan over all keys for each nested FormData), the new code advances a shared pointer linearly and breaks early when it exits the relevant key range. This closes the O(n²) DoS vector in CVE-2026-23870.
  4. Stream-vs-model chunk guard — new check prevents a stream chunk from being treated as a model chunk when decoding outlined models.
  5. $$consumed ordering fix — flag is now set before new Map()/new Set()/[Symbol.iterator](), preventing re-use if construction throws.

Correctness

Strengths:

  • The delta-preservation check (verifying diff(stock 19.0.7, vendored-after) == diff(stock 19.0.4, vendored-before)) is the right way to validate a vendored artifact patch.
  • The manual hunk for the DevTools block in client.browser.development.js correctly preserves rendererPackageName: "react-on-rails-rsc".
  • The decodeReplyFromBusboy linked-list refactor is more correct than the old counter: fields are now queued only behind the immediately preceding in-flight upload, preserving field ordering at a finer granularity.
  • File stream errors are now propagated (value.on("error", ...) handler added) — the old code silently dropped file stream errors.

Concerns:

1. Wire-format breaking change is a hard compatibility boundary

The prefix change means any client running >=19.0.5 paired with a server running <19.0.5 (or vice versa) will silently drop all nested FormData entries. The CHANGELOG note is correct. Worth verifying that the consuming app always updates client and server atomically — if there is any CDN/SSR split-deploy scenario where cached client JS runs at the old version while the server is already updated, nested FormData payloads (Server Actions with nested FormData arguments) will silently fail.

2. Test coverage for the specific security scenarios

The test plan covers the CSS Flight hint regression and overall suite pass, but there are no tests for:

  • Nested FormData round-trip (encode via client, decode via server) — the exact path patched by the CVEs
  • The decodeReplyFromBusboy linked-list ordering (field queued behind specific preceding file, not all files)
  • The stream-vs-model chunk guard (invalid payload detection)

A minimal test exercising nested FormData encode/decode would meaningfully strengthen confidence in a security-critical patch.

3. keyPointer: -1 initial value is a minor inconsistency

createResponse initialises keyPointer: -1, but both resolveField and the nested-FormData decode path set it to 0 on first use. The -1 is effectively dead — the keys: null sentinel is what drives first-use detection. Not a bug, but worth noting for future delta-verification against upstream.

4. flush() double-error path in decodeReplyFromBusboy

After flush() catches an error and calls busboyStream.destroy(error) (returning early, leaving closed = false), the finish handler also calls reportGlobalError(..., "Reply finished with incomplete file part."). In practice reportGlobalError is idempotent after the first error, but a Blob-construction failure will surface as the misleading "incomplete file part" message. This is upstream behavior, not introduced here — flagging for awareness.


Documentation


Verdict

Approve with non-blocking suggestions. The security fixes are correctly applied and the approach is sound. Two items worth considering before merge:

  1. Smoke-test nested FormData with a quick integration test or manual verification in the demo app to confirm the wire-format change works end-to-end.
  2. Document the split-deploy risk near decodeReplyFromBusboy or in the PR description for operators who cache client JS separately from the server runtime.

The vendored runtime will be replaced by #60, so the bar for polish here is appropriately lower than a first-party change.

var data$32 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wire-format breaking change (CVE-2026-23869 fix): the separator "_" is now inserted between the prefix and the numeric part-ID, changing the on-the-wire field name from <prefix><id>_<key> to <prefix>_<id>_<key>. This means any client that has been bumped past 19.0.5 must pair with a server on the same version — a mismatched pair silently loses all nested FormData entries rather than throwing an error.

The same change is applied consistently across all 8 client build variants (browser/edge/node × dev/prod), which is correct.

_bundlerConfig: bundlerConfig,
_prefix: formFieldPrefix,
_formData: backingFormData,
_formData: { data: backingFormData, keyPointer: -1, keys: null },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor inconsistency: keyPointer is initialised to -1 here, but both resolveField and the $K decode path set it to 0 the first time the keys array is built (lazily, when keys is null). The -1 value is never read before being overwritten, so this is not a bug — keys: null is the actual first-use sentinel. Still worth noting when diffing against future upstream stock versions: if upstream uses 0 here, this will appear as a spurious delta in the delta-preservation check.

chunk = chunk.reason;
if (null !== chunk && "error" in chunk)
throw Error(
"Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream-vs-model chunk guard (new in 19.0.7): after a fulfilled model chunk is retrieved, this checks that chunk.reason (the secondary slot) is not a stream controller object. Without this guard, a payload crafted to overlap model and stream chunk IDs could confuse the decoder into reading a stream controller as a model value. The error message ("submitted by an older version of React") is intentionally user-facing to aid debugging of version mismatches.

if (!isArrayImpl(model)) throw Error("Invalid Map initializer.");
if (!0 === model.$$consumed) throw Error("Already initialized Map.");
response = new Map(model);
model.$$consumed = !0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$$consumed ordering fix: in the old code, new Map(model) was called first and the result stored in response, then model.$$consumed = !0 was set. If new Map() threw (e.g., due to a non-iterable element), the $$consumed flag would never be set, allowing a second attempt to decode the same model array. Moving the flag assignment before construction ensures the guard is set even on failure, preventing potential double-initialisation. The same pattern is applied to createSet and extractIterator.

}
head = current.next;
}
tail = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linked-list queue replaces the file counter (correctness improvement): the old pendingFiles counter held back all fields as soon as any upload started, even if those fields appeared in the stream after a different upload that had already completed. The linked-list (head/tail) scopes queuing to the immediately preceding in-flight upload, so fields can be flushed as soon as all uploads ahead of them in the stream are done.

tail = null here is correct — this line is only reached when the while (head !== null) loop exits, meaning all queued items have been processed. If !current.complete fires mid-loop, the early return prevents reaching this statement.

closed ||
reportGlobalError(
response,
Error("Reply finished with incomplete file part.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential misleading error message: if flush() catches a Blob-construction error and calls busboyStream.destroy(error), it returns early with closed still false. The finish handler then also calls reportGlobalError with "Reply finished with incomplete file part." Since reportGlobalError is idempotent (a second call after the first error is a no-op), this does not cause a double-error in practice. However, debugging a Blob error in production will show only the "incomplete file part" message, not the real cause. Upstream behavior, but worth knowing.

…0.7-cve-dos-fixes

* origin/main:
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)

# Conflicts:
#	CHANGELOG.md
@justin808

Copy link
Copy Markdown
Member Author

Conflict-resolution validation

Updated this branch with origin/main via merge commit 2fa0530, resolving the CHANGELOG.md conflict by preserving both the #87 ### Changed entry and the #86 ### Fixed security entry/link.

Local validation on current head 2fa0530b8189ab0d41e422a6c1f343bb7f4063f6:

  • yarn test — passed (17 suites / 158 tests)
  • yarn build — passed (tsc clean)

GitHub checks at last poll:

Per the pr-batch merge gate, I did not merge while the current-head claude-review check is still in progress.

…0.7-cve-dos-fixes

* origin/main:
  Make PR skill workflows discoverable (#88)
Comment thread CHANGELOG.md
### Changed
- Ported the webpack RSC plugin from the vendored built JavaScript artifact (`src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js`) to first-class TypeScript source at `src/webpack/RSCWebpackPlugin.ts`, preserving full behavior parity (server manifest emission, CSS/JS chunk scanning, runtime-chunk filtering, dependency-type chunk-group manifest construction with eager-import fallback, duplicate-package runtime detection, and hot-update CSS exclusion). The `./WebpackPlugin`, `./WebpackLoader`, and `./RSCReferenceDiscoveryPlugin` exports are unchanged; the vendored plugin file remains in the package but is no longer used by any export path. ([#87])

### Fixed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using ### Security instead of ### Fixed here. Per Keep-a-Changelog convention, the Security section is specifically for CVE/vulnerability fixes and is recognised by security-scanner tooling. The current Fixed heading works, but Security is more precise given that both changes are CVE patches.

Suggested change
### Fixed
### Security

Comment on lines +102 to +103
"react": "^19.0.7",
"react-dom": "^19.0.7",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bumping the vendored peerDependencies to ^19.0.7 means npm will emit an unmet peer warning for any consumer running React 19.0.4-19.0.6. This is the stock 19.0.7 value and the PR description notes this file is "internal and not resolved by npm" — but that is only guaranteed if the shipped package excludes this package.json from its published files. It would be worth either (a) confirming the outer package.json files field omits src/react-server-dom-webpack/package.json, or (b) adding a note in the CHANGELOG/deployment docs that users on 19.0.4-19.0.6 may see a peer warning they can safely ignore until #60 lands.

_bundlerConfig: bundlerConfig,
_prefix: formFieldPrefix,
_formData: backingFormData,
_formData: { data: backingFormData, keyPointer: -1, keys: null },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (non-blocking — verbatim upstream code): keyPointer: -1 is a safe sentinel because keys: null is always checked first and keyPointer is reset to 0 before it is ever read as an array index. However, keys: null already fully encodes the "uninitialized" state, so initialising keyPointer to 0 here would be equally correct and removes the off-by-one footgun if future code reads keyPointer without first checking keys !== null.

Comment on lines +2545 to +2548
if (null !== chunk && "error" in chunk)
throw Error(
"Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React."
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version-mismatch guard (CVE-2026-23870): A "fulfilled" chunk that still carries an "error" property is a stream chunk, not a plain data chunk — this shape is produced by pre-19.0.5 servers using the old wire format. Throwing here prevents the decoder from misinterpreting stream-framing metadata as model data, which could otherwise cause silent data corruption or a DoS depending on what the payload contains. The error message correctly surfaces the likely root cause (mismatched runtime versions) rather than a cryptic type error deeper in the decode path.

Comment on lines 2630 to 2631
if (!0 === model.$$consumed) throw Error("Already initialized Map.");
response = new Map(model);
model.$$consumed = !0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$$consumed ordering fix: Setting the flag before constructing new Map(model) / new Set(model) means that even if the constructor throws (e.g. OOM, or a custom iterator that raises), the guard is already armed. Under the old ordering, a throw between construction and the flag-set would leave $$consumed = false, allowing a second call to re-initialize the same model array — a potential re-use vector. This ordering is the correct, hardened form.

Comment on lines 3400 to +3407
busboyStream.on("finish", function () {
close(response);
bodyFinished = !0;
flush();
closed ||
reportGlobalError(
response,
Error("Reply finished with incomplete file part.")
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Truncated-upload detection: Under the old code, busboyStream.on("finish") called close(response) unconditionally, so a connection that dropped mid-file would silently close the response with whatever partial data was already resolved. The new logic only calls close (via flush) if closed was already set to true inside the loop — meaning all pending files completed and were processed. If any file node is still complete: false when finish fires, closed remains false and this error path fires, correctly propagating a fatal error to the response rather than silently truncating.

One edge case to be aware of: if flush() itself calls busboyStream.destroy(error) on a processing error, the "error" event will fire after "finish", so reportGlobalError may be called twice (once from flush and once from the "error" handler). The second call is benign since reportGlobalError on an already-errored response is a no-op in React's error handling.

@justin808

Copy link
Copy Markdown
Member Author

Current-head review triage

Current head: b44cc671c2a5380ea0ab5fce251f40ad0a461815.

Local validation on this head:

  • yarn test — passed (17 suites / 158 tests)
  • yarn build — passed (tsc clean)

GitHub checks on this head:

  • unit-tests — pass
  • e2e-tests — pass
  • claude-review — pass
  • CodeRabbit — pass / review completed
  • Cursor Bugbot — pass
  • claude — skipped (event-gated workflow row; not a required/current review gate)

New current-head Claude feedback disposition:

Comment Class Resolution
CHANGELOG ### Fixed vs ### Security optional → declined Repo AGENTS.md documents changelog group headings as Added/Changed/Fixed/Removed. The current entry is valid under the local convention, and changing only the heading would create another review/check cycle for style churn.
Vendored peerDependencies ^19.0.7 vs outer ^19.0.4 optional → verified/declined The outer package manifest remains the npm peer contract. The vendored runtime manifest is upstream artifact metadata; even though it is copied under dist/react-server-dom-webpack/package.json, npm does not resolve nested peer dependencies as the package's peer contract. The mixed-runtime risk is already documented in the PR body and CHANGELOG, and #60 owns the deliberate outer peer dependency decision.
Busboy destroy(error)/flush() edge case noise This is explicitly noted as verbatim upstream React 19.0.7 behavior and safe in practice; preserving upstream byte-for-byte behavior is the point of this stopgap.
Wire-format, stream guard, 18070consumed, truncated-upload, keyPointer notes noise / non-blocking Explanatory confirmations of the upstream security fixes; no code change requested or appropriate in vendored compiled artifacts.

No current-head blocker remains from review feedback.

…0.7-cve-dos-fixes

* origin/main:
  Harden dependency and Claude automation (#76)
Comment thread CHANGELOG.md
### Changed
- Ported the webpack RSC plugin from the vendored built JavaScript artifact (`src/react-server-dom-webpack/cjs/react-server-dom-webpack-plugin.js`) to first-class TypeScript source at `src/webpack/RSCWebpackPlugin.ts`, preserving full behavior parity (server manifest emission, CSS/JS chunk scanning, runtime-chunk filtering, dependency-type chunk-group manifest construction with eager-import fallback, duplicate-package runtime detection, and hot-update CSS exclusion). The `./WebpackPlugin`, `./WebpackLoader`, and `./RSCReferenceDiscoveryPlugin` exports are unchanged; the vendored plugin file remains in the package but is no longer used by any export path. ([#87])

### Fixed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using ### Security instead of ### Fixed here. Per Keep-a-Changelog convention, the Security section is specifically for CVE/vulnerability fixes and is recognised by security-scanner tooling. The current Fixed heading works, but Security is more precise given that both changes are CVE patches.

Suggested change
### Fixed
### Security

Comment on lines +102 to +103
"react": "^19.0.7",
"react-dom": "^19.0.7",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bumping the vendored peerDependencies to ^19.0.7 means npm will emit an unmet peer warning for any consumer running React 19.0.4-19.0.6. This is the stock 19.0.7 value and the PR description notes this file is "internal and not resolved by npm" — but that is only guaranteed if the shipped package excludes this package.json from its published files. It would be worth either (a) confirming the outer package.json files field omits src/react-server-dom-webpack/package.json, or (b) adding a note in the CHANGELOG/deployment docs that users on 19.0.4-19.0.6 may see a peer warning they can safely ignore until #60 lands.

Comment on lines +3334 to +3335
busboyStream.destroy(error);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After busboyStream.destroy(error), the function returns without advancing head = current.next. If another file's end event fires before destroy() fully suppresses events (edge case in the same microtask), flush() would re-enter and retry the same failed node. Busboy's destroy() is reliable in practice, so this is safe — and this code is verbatim from upstream React 19.0.7 — but the gap is worth noting for anyone who later adapts flush() to a different stream implementation.

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code Review: Apply React 19.0.7 RSC reply-decode DoS fixes (see inline comments for details)

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code Review: Apply React 19.0.7 RSC reply-decode DoS fixes

Please see the three inline comments posted on this PR for specific line-level feedback.

Overview

This PR applies the upstream React 19.0.5-19.0.7 security patches to the vendored react-server-dom-webpack runtime, fixing two DoS CVEs in RSC reply decoding. The approach (patching CJS artifacts in place, preserving fork deltas) is consistent with how this vendored tree has been managed before, and is appropriate given that #60 (stock npm migration) has not landed yet.

The changes are applied across all 12 server build variants and all 8 client build variants. The delta-preservation claim (stock 19.0.7 + pre-existing fork deltas) appears sound based on the diff.


What the patch does

Client (all 8 variants — wire format fix for CVE-2026-23869):
Nested FormData field keys now use formFieldPrefix + "_" + partId + "_" + originalKey instead of omitting the first "_" separator. Applied consistently. The rendererPackageName: "react-on-rails-rsc" fork value is correctly preserved in the one file that contains it (the dev build DevTools registration block).

Server (all 12 variants — CVE-2026-23869 + CVE-2026-23870):

  • _formData is now a wrapper { data, keys, keyPointer } instead of a raw FormData. All _formData.get/getAll/append/delete call sites updated to _formData.data.* — appears complete.
  • $K FormData reconstruction uses an incremental key pointer scan instead of materialising all keys at once (removes the Array.from(keys()) DoS vector).
  • $$consumed flag is now set before Map/Set/Iterator construction (prevents double-initialisation if the constructor throws).
  • "Initialized stream chunk" guard added to detect mismatched client/server versions with a clear error rather than silent corruption.

Node-only (decodeReplyFromBusboy):
Replaced pendingFiles counter + global queuedFields[] with a typed linked-list of pending file nodes. Fields arriving while a file is in-flight are associated with that specific file node rather than globally queued, so a multi-file upload correctly preserves per-file field ordering. A previously missing value.on("error", ...) handler is added. The finish event now errors on an incomplete file part rather than silently closing.


Issues

1. CHANGELOG section heading — minor (see inline comment on CHANGELOG.md line 10)
The entry is under ### Fixed. Per Keep-a-Changelog, CVE patches belong under ### Security. This affects how security scanners and release-note consumers categorise the entry.

2. Vendored package.json peerDependency bump — minor (see inline comment on src/react-server-dom-webpack/package.json lines 102–103)
"react": "^19.0.7" and "react-dom": "^19.0.7" will cause npm to warn for users who have React 19.0.4–19.0.6 installed. The PR notes this package.json is "internal and not resolved by npm" — that claim holds as long as the vendored directory is never installed as a standalone package. Worth confirming the published bundle excludes the vendored package.json, or explicitly documenting that this peer warning is expected.

3. flush() error path leaves head un-advanced — low risk (see inline comment on server.node.production.js lines 3334–3335)
In the catch block of flush(), busboyStream.destroy(error) is called and the function returns before head = current.next. If flush() is called a second time before stream teardown fully suppresses events (e.g., another file's end fires in the same microtask), the same failed node would be retried. Busboy destroy is reliable in practice, so this is low risk and mirrors upstream 19.0.7 behaviour verbatim.

4. Double reportGlobalError path on flush error in finish handler — low risk
In the finish handler: flush() may call busboyStream.destroy(error) (which asynchronously triggers reportGlobalError via the "error" listener), and then the finish handler synchronously calls reportGlobalError(response, Error("Reply finished with incomplete file part.")) because closed is still false. React response error state is idempotent, so this is safe — but it is a subtle double-report path inherited from upstream.


What is correct and well-handled

  • Wire format change applied to all 8 client variants (browser/edge/node/node.unbundled x dev+prod) ✓
  • _formData.data wrapper applied to all 12 server variants ✓
  • $$consumed ordering fixed before construction in all server variants ✓
  • Incremental $K key scanning in all server variants ✓
  • resolveField key tracking updated in node variants (keys array stays in sync) ✓
  • rendererPackageName: "react-on-rails-rsc" preserved in the dev client DevTools block ✓
  • Post-patch smoke test verifies nested FormData round-trip end-to-end ✓
  • Deployment note about split client/server rollout prominently documented ✓
  • CHANGELOG entry and PR description are thorough and accurate ✓

Verdict

Approve with minor notes. The security patches are faithfully applied and the core logic is correct. Items 1–2 are worth addressing before the release tag; items 3–4 are informational observations about upstream behaviour inherited verbatim.

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Code Review: React 19.0.7 CVE patch on vendored runtime

Overview

This is a well-executed security backport. The diff is faithful to upstream 19.0.7, all 16 vendored CJS variants are updated consistently, the fork-specific rendererPackageName is correctly preserved in the dev bundle, and the CHANGELOG documents both the wire-format breaking change and the split-rollout risk. The PR's delta-preservation claim (stock 19.0.7 + pre-existing fork deltas) is convincing. Recommended to merge.


What the patch does (summary for reviewers)

Area Change CVE
Client encodeReply Prefix for nested FormData keys gains a leading _ separator CVE-2026-23869
Server _formData wrapper Raw FormData replaced by { data, keyPointer, keys } CVE-2026-23869
Server case "K" key-pointer walk Replaces full Array.from(formData.keys()) scan with a pointer scan that stops at the first non-matching prefix boundary CVE-2026-23869
Server getOutlinedModel "initialized stream chunk" guard detects old-format payloads CVE-2026-23870
Server createMap/Set/extractIterator $$consumed is set before construction, not after Correctness
Server decodeReplyFromBusboy Counter+shared-queue replaced by per-file linked list, preserving field ordering per the multipart spec; adds missing value.on("error") handler Correctness

Issues

1. Test gap: decodeReplyFromBusboy ordering fix is untested

The test plan verifies encodeReply -> decodeReply (URL-encoded path) and the nested FormData round-trip in client.browser. The busboy path (decodeReplyFromBusboy) is not exercised by the test suite at all.

The old code queued all fields arriving while any file was in-flight into a single shared queuedFields array, flushing them only when every file was done. The new code queues fields per-file-node in the linked list. The behavioral difference matters when a multipart body has the form:

field_a=before
--file_1 (still uploading)
field_b=during  <-- queued on file_1's node in new code; on shared array in old code
--file_2
field_c=after_file_2  <-- queued on file_2's node in new code

This is the correct behavior per multipart ordering, but it is a regression risk if any caller relied on the (incorrect) old all-or-nothing flush semantics. Suggest adding a busboy integration test, or opening a tracking issue if out of scope for this stopgap.

2. keyPointer: -1 never-read initial value (minor, inline comment below)

The initial value of -1 for keyPointer is never read in the state where keys === null, since all access sites check keys === null first and reset keyPointer to 0 when initializing. The value is harmless but could be 0 without behavioral change. Since this is a vendored artifact, keeping it as-is to stay true to upstream is the right call — just flagging it for awareness.

3. flush() does not set closed = true on the error path (nit)

In decodeReplyFromBusboy's flush(), when an error is caught, busboyStream.destroy(error) is called and the function returns early without setting closed = true. If bodyFinished was already true and a queued finish event fires before destroy removes listeners, flush() would be called again and reach close(response) on an already-error-reported response. close / reportGlobalError is effectively idempotent so this won't corrupt state, but setting closed = true before the return would make the intent explicit. Not a blocking issue.


What is correct and should not change

  • $$consumed ordering — setting the flag before construction is the correct fix for preventing a retry-on-throw attack; see inline comment.
  • case "K" early-break on prefix boundary — stopping at the first key that shares the obj prefix but not the specific key prefix is safe because the client always emits a given FormData's keys contiguously. Interleaved keys in a crafted malicious payload silently truncate rather than crash, which is the intended hardened behavior.
  • Fork delta preservationrendererPackageName: "react-on-rails-rsc" is correctly retained in the dev bundle only; the production bundle has no DevTools registration block, so no action was needed there.

if (!isArrayImpl(model)) throw Error("Invalid Map initializer.");
if (!0 === model.$$consumed) throw Error("Already initialized Map.");
response = new Map(model);
model.$$consumed = !0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct fix for the $$consumed double-initialization vulnerability.

Old order:

response = new Map(model);   // construct first
model.$$consumed = !0;       // mark consumed after — if constructor throws, flag never set

New order (this PR):

model.$$consumed = !0;       // mark consumed first — even if constructor throws, retry is blocked
return new Map(model);

The old order allowed an attacker to craft a payload that caused new Map(model) to throw, then retry the same model since $$consumed was never set. The new order marks the model consumed before any potentially-throwing operation, closing that window. The tradeoff — if construction legitimately fails, the model is silently marked consumed and the value is lost — is the correct security choice here since this is untrusted input decoding.

arrayRoot.append(referencedFormDataKey, reference[i]);
response.data.delete(value);
response.keyPointer++;
} else if (value.startsWith(obj)) break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This early-break is the core DoS mitigation for CVE-2026-23869 — correct but subtle.

The loop processes FormData keys in order using a pointer. When it hits a key that starts with obj (the overall prefix, e.g. "0_") but NOT with key (the specific FormData prefix, e.g. "0_3_"), it breaks. This works because the client's encodeReply always emits all keys for a given nested FormData contiguously — so once you see a key for a different FormData part (same prefix, different ID), there are no more keys for the current one.

The security win: the old code did Array.from(response.keys()) materializing the entire key set into memory up front, then scanned every entry. An attacker could stuff thousands of unrelated keys into the form to make every "K" decode O(n). The new pointer scan stops early and pays O(matching keys + 1 lookahead).

Edge case: a crafted payload with interleaved keys (keys for FormData A, then FormData B, then FormData A again) would silently truncate FormData A's second batch rather than crash. This is acceptable — legitimate clients never produce this ordering.

@justin808

Copy link
Copy Markdown
Member Author

Current-head review triage addendum

Current head: 25bba9f8d9e78819dc1c463dec0a5e3f1dbc756c.

Latest Claude review comments after the prior triage are non-blocking:

Comment Class Resolution
Duplicate ### Security changelog heading suggestion optional → declined Same disposition as before: local AGENTS.md documents changelog groups as Added/Changed/Fixed/Removed; ### Fixed is valid here and avoids another style-only CI/review cycle.
Duplicate vendored peer dependency visibility note optional → verified/declined Same disposition as before: the root package manifest remains the npm peer contract. The vendored/copied runtime manifest is upstream artifact metadata; mixed-runtime deployment risk is already documented in the PR body and changelog, and #60 owns deliberate outer peer dependency changes.
39701consumed and FormData key-scan annotations noise / confirmation These confirm the upstream CVE hardening and do not request a change. The code intentionally preserves upstream 19.0.7 built artifacts except existing fork deltas.

No current-head review blocker remains.

@justin808 justin808 merged commit 36e2536 into main Jun 12, 2026
10 checks passed
justin808 added a commit that referenced this pull request Jun 12, 2026
Merge criteria satisfied: full GitHub check list green (unit-tests, e2e-tests, verify-artifacts, claude-review, Greptile, Cursor Bugbot, CodeRabbit; Claude workflow skipped), mergeStateStatus CLEAN / mergeable MERGEABLE, and all review threads resolved or explicitly triaged. Local evidence: yarn install --frozen-lockfile, yarn verify:artifacts, yarn build, git diff --check. This restores main health after the #77 artifact gate exposed the #86 runtime/root-peer policy mismatch; no publish, tag, release, or registry mutation was run.
justin808 added a commit that referenced this pull request Jun 12, 2026
* origin/main:
  Add release artifact verification gate (#77)
  Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime (#86)
  Harden dependency and Claude automation (#76)
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)
justin808 added a commit that referenced this pull request Jun 12, 2026
…h-tests

* origin/main:
  Add release artifact verification gate (#77)
  Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime (#86)
  Harden dependency and Claude automation (#76)
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)
justin808 added a commit that referenced this pull request Jun 12, 2026
…atrix

* origin/main:
  Allow artifact verifier to accept covering root peer ranges (#95)
  Add release artifact verification gate (#77)
  Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime (#86)
  Harden dependency and Claude automation (#76)
justin808 added a commit that referenced this pull request Jun 12, 2026
* origin/main:
  Add compatibility matrix and canary signal (#83)
  Document runtime versioning policy and live backlog pointers (#79)
  Add Flight client error path coverage (#78)
  Document release dist-tag policy and artifact parity checks (#75)
  Allow artifact verifier to accept covering root peer ranges (#95)
  Add release artifact verification gate (#77)
  Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime (#86)
  Harden dependency and Claude automation (#76)
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)

# Conflicts:
#	docs/releasing.md
justin808 added a commit that referenced this pull request Jun 13, 2026
…ase-motion

* origin/main:
  Release from GitHub Actions with trusted publishing (#84)
  Add Claude agent setup and workflow skill stubs (#82)
  Add compatibility matrix and canary signal (#83)
  Document runtime versioning policy and live backlog pointers (#79)
  Add Flight client error path coverage (#78)
  Document release dist-tag policy and artifact parity checks (#75)
  Allow artifact verifier to accept covering root peer ranges (#95)
  Add release artifact verification gate (#77)
  Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime (#86)
  Harden dependency and Claude automation (#76)
  Make PR skill workflows discoverable (#88)
  Extract and own the webpack RSC plugin as TypeScript source (#87)
  Add packed-tarball E2E pipeline suite: webpack+rspack → Flight → SSR HTML → hydration (#85)
  Docs: Option 5 stock npm runtime go/no-go decision (#55 spike) (#80)

# Conflicts:
#	CHANGELOG.md
@cursor cursor Bot mentioned this pull request Jun 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move the runtime to React 19.2.x (stock npm package, or 2-patch rebuild fallback)

1 participant