-
Notifications
You must be signed in to change notification settings - Fork 1
Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Apply React 19.0.7 RSC reply-decode DoS fixes (CVE-2026-23869, CVE-2026-23870) to vendored runtime #86
Changes from all commits
67ae3c2
40d7124
ac16c86
2fa0530
b44cc67
25bba9f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -7,6 +7,9 @@ All notable changes to this package will be documented in this file. | |||||
| ### 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 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider using
Suggested change
|
||||||
| - Updated the vendored `react-server-dom-webpack` runtime from the React 19.0.4 security level to 19.0.7, applying the upstream React Server Components reply-decoding denial-of-service fixes for CVE-2026-23869 (GHSA-479c-33wc-g2pg) and CVE-2026-23870 (GHSA-rv78-f8rc-xrxh) while preserving the in-repo Flight CSS hint behavior. Note: the upstream CVE-2026-23869 fix changes the reply wire format for nested `FormData`, so client and server must both run the patched runtime (both are shipped together in this package); pairing with a pre-19.0.5 `react-server-dom-webpack` on the other side silently drops nested `FormData` entries. ([#86]) | ||||||
|
|
||||||
| ## [19.0.5-rc.7] - 2026-06-09 | ||||||
|
|
||||||
| ### Added | ||||||
|
|
@@ -85,4 +88,5 @@ All notable changes to this package will be documented in this file. | |||||
| [19.0.5-rc.1]: https://github.com/shakacode/react_on_rails_rsc/releases/tag/19.0.5-rc.1 | ||||||
|
|
||||||
| [#52]: https://github.com/shakacode/react_on_rails_rsc/pull/52 | ||||||
| [#86]: https://github.com/shakacode/react_on_rails_rsc/pull/86 | ||||||
| [#87]: https://github.com/shakacode/react_on_rails_rsc/pull/87 | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -346,7 +346,7 @@ function processReply( | |
| null === formData && (formData = new FormData()); | ||
| var data$32 = formData; | ||
| key = nextPartId++; | ||
| var prefix = formFieldPrefix + key + "_"; | ||
| var prefix = formFieldPrefix + "_" + key + "_"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wire-format breaking change. The old prefix was 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 However, a 19.0.4 client sending a 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wire-format breaking change (CVE-2026-23869 fix): the separator The same change is applied consistently across all 8 client build variants (browser/edge/node × dev/prod), which is correct. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CVE-2026-23869 wire-format fix — The inserted Note the breaking implication: a client running this build talking to a pre-19.0.5 server (or vice versa) will silently produce mismatched keys and drop nested FormData entries with no error thrown. Deploy client and server together. |
||
| value.forEach(function (originalValue, originalKey) { | ||
| data$32.append(prefix + originalKey, originalValue); | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2405,7 +2405,7 @@ function getChunk(response, id) { | |
| var chunks = response._chunks, | ||
| chunk = chunks.get(id); | ||
| chunk || | ||
| ((chunk = response._formData.get(response._prefix + id)), | ||
| ((chunk = response._formData.data.get(response._prefix + id)), | ||
| (chunk = | ||
| "string" === typeof chunk | ||
| ? createResolvedModelChunk(response, chunk, id) | ||
|
|
@@ -2504,6 +2504,10 @@ function getOutlinedModel( | |
| case "fulfilled": | ||
| id = chunk.value; | ||
| chunk = chunk.reason; | ||
| if (null !== chunk && "error" in chunk) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good guard. A fulfilled chunk whose |
||
| 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." | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stream-vs-model chunk guard (new in 19.0.7): after a |
||
| ); | ||
| for ( | ||
| var localLength = 0, | ||
| rootArrayContexts = response._rootArrayContexts, | ||
|
|
@@ -2586,23 +2590,20 @@ function getOutlinedModel( | |
| function createMap(response, model) { | ||
| if (!isArrayImpl(model)) throw Error("Invalid Map initializer."); | ||
| if (!0 === model.$$consumed) throw Error("Already initialized Map."); | ||
| response = new Map(model); | ||
| model.$$consumed = !0; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Subtle but important ordering fix. Setting There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security fix: Previously the order was: response = new Map(model); // ← runs first
model.$$consumed = !0; // ← set second
return response;If This pattern is applied consistently to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct fix for the Old order: response = new Map(model); // construct first
model.$$consumed = !0; // mark consumed after — if constructor throws, flag never setNew 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 |
||
| return response; | ||
| return new Map(model); | ||
| } | ||
| function createSet(response, model) { | ||
| if (!isArrayImpl(model)) throw Error("Invalid Set initializer."); | ||
| if (!0 === model.$$consumed) throw Error("Already initialized Set."); | ||
| response = new Set(model); | ||
| model.$$consumed = !0; | ||
| return response; | ||
| return new Set(model); | ||
| } | ||
| function extractIterator(response, model) { | ||
| if (!isArrayImpl(model)) throw Error("Invalid Iterator initializer."); | ||
| if (!0 === model.$$consumed) throw Error("Already initialized Iterator."); | ||
| response = model[Symbol.iterator](); | ||
| model.$$consumed = !0; | ||
| return response; | ||
| return model[Symbol.iterator](); | ||
| } | ||
| function createModel(response, model, parentObject, key) { | ||
| return "then" === key && "function" === typeof model ? null : model; | ||
|
|
@@ -2640,7 +2641,7 @@ function parseTypedArray( | |
| Error("Already initialized typed array.") | ||
| ) | ||
| ); | ||
| reference = response._formData.get(key).arrayBuffer(); | ||
| reference = response._formData.data.get(key).arrayBuffer(); | ||
| if (initializingHandler) { | ||
| var handler = initializingHandler; | ||
| handler.deps++; | ||
|
|
@@ -2684,7 +2685,7 @@ function resolveStream(response, id, stream, controller) { | |
| var chunks = response._chunks; | ||
| stream = new ReactPromise("fulfilled", stream, controller); | ||
| chunks.set(id, stream); | ||
| response = response._formData.getAll(response._prefix + id); | ||
| response = response._formData.data.getAll(response._prefix + id); | ||
| for (id = 0; id < response.length; id++) | ||
| (chunks = response[id]), | ||
| "string" === typeof chunks && | ||
|
|
@@ -2899,24 +2900,31 @@ function parseModelString(response, obj, key, value, reference, arrayRoot) { | |
| getOutlinedModel(response, arrayRoot, obj, key, null, createSet) | ||
| ); | ||
| case "K": | ||
| obj = value.slice(2); | ||
| obj = response._prefix + obj + "_"; | ||
| key = new FormData(); | ||
| response = response._formData; | ||
| arrayRoot = Array.from(response.keys()); | ||
| for (value = 0; value < arrayRoot.length; value++) | ||
| if (((reference = arrayRoot[value]), reference.startsWith(obj))) { | ||
| key = value.slice(2); | ||
| obj = response._prefix + "_"; | ||
| key = obj + key + "_"; | ||
| arrayRoot = new FormData(); | ||
| for (response = response._formData; ; ) { | ||
| value = response.keys; | ||
| null === value && | ||
| ((value = response.keys = Array.from(response.data.keys())), | ||
| (response.keyPointer = 0)); | ||
| value = value[response.keyPointer]; | ||
| if (void 0 === value) break; | ||
| if (value.startsWith(key)) { | ||
| reference = response.data.getAll(value); | ||
| for ( | ||
| var entries = response.getAll(reference), | ||
| newKey = reference.slice(obj.length), | ||
| j = 0; | ||
| j < entries.length; | ||
| j++ | ||
| var referencedFormDataKey = value.slice(key.length), i = 0; | ||
| i < reference.length; | ||
| i++ | ||
| ) | ||
| key.append(newKey, entries[j]); | ||
| response.delete(reference); | ||
| } | ||
| return key; | ||
| arrayRoot.append(referencedFormDataKey, reference[i]); | ||
| response.data.delete(value); | ||
| response.keyPointer++; | ||
| } else if (value.startsWith(obj)) break; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Implicit invariant: nested-FormData keys must be contiguous This This holds because the client ( 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The security win: the old code did 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. |
||
| else response.keyPointer++; | ||
| } | ||
| return arrayRoot; | ||
| case "i": | ||
| return ( | ||
| (arrayRoot = value.slice(2)), | ||
|
|
@@ -3077,7 +3085,7 @@ function parseModelString(response, obj, key, value, reference, arrayRoot) { | |
| case "B": | ||
| return ( | ||
| (obj = parseInt(value.slice(2), 16)), | ||
| response._formData.get(response._prefix + obj) | ||
| response._formData.data.get(response._prefix + obj) | ||
| ); | ||
| } | ||
| switch (value[1]) { | ||
|
|
@@ -3107,7 +3115,7 @@ function createResponse(bundlerConfig, formFieldPrefix, temporaryReferences) { | |
| return { | ||
| _bundlerConfig: bundlerConfig, | ||
| _prefix: formFieldPrefix, | ||
| _formData: backingFormData, | ||
| _formData: { data: backingFormData, keyPointer: -1, keys: null }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor inconsistency: |
||
| _chunks: chunks, | ||
| _temporaryReferences: temporaryReferences, | ||
| _rootArrayContexts: new WeakMap(), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider using
### Securityinstead of### Fixedhere. Per Keep-a-Changelog convention, theSecuritysection is specifically for CVE/vulnerability fixes and is recognised by security-scanner tooling. The currentFixedheading works, butSecurityis more precise given that both changes are CVE patches.