Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

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

- 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
Expand Down Expand Up @@ -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
Expand Up @@ -555,7 +555,7 @@
null === formData && (formData = new FormData());
var _data3 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";
value.forEach(function (originalValue, originalKey) {
_data3.append(prefix + originalKey, originalValue);
});
Expand Down Expand Up @@ -2511,10 +2511,10 @@
return hook.checkDCE ? !0 : !1;
})({
bundleType: 1,
version: "19.0.4",
version: "19.0.7",
rendererPackageName: "react-on-rails-rsc",
currentDispatcherRef: ReactSharedInternals,
reconcilerVersion: "19.0.4",
reconcilerVersion: "19.0.7",
getCurrentComponentInfo: function () {
return currentOwnerInDEV;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "_";

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.

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.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CVE-2026-23869 wire-format fix — The inserted "_" between formFieldPrefix and key disambiguates nested FormData field keys from other reply parts that share the same prefix. Without the separator, a crafted payload where a part ID happens to be a prefix of a FormData key could alias into the wrong bucket on decode. The same change is applied in all 7 other client variants; the server-side $K decoder now builds its search prefix as response._prefix + "_" + partId + "_" to match.

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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@
null === formData && (formData = new FormData());
var _data3 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";
value.forEach(function (originalValue, originalKey) {
_data3.append(prefix + originalKey, originalValue);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ function processReply(
null === formData && (formData = new FormData());
var data$32 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";
value.forEach(function (originalValue, originalKey) {
data$32.append(prefix + originalKey, originalValue);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@
null === formData && (formData = new FormData());
var _data3 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";
value.forEach(function (originalValue, originalKey) {
_data3.append(prefix + originalKey, originalValue);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ function processReply(
null === formData && (formData = new FormData());
var data$32 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";
value.forEach(function (originalValue, originalKey) {
data$32.append(prefix + originalKey, originalValue);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@
null === formData && (formData = new FormData());
var _data3 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";
value.forEach(function (originalValue, originalKey) {
_data3.append(prefix + originalKey, originalValue);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ function processReply(
null === formData && (formData = new FormData());
var data$32 = formData;
key = nextPartId++;
var prefix = formFieldPrefix + key + "_";
var prefix = formFieldPrefix + "_" + key + "_";
value.forEach(function (originalValue, originalKey) {
data$32.append(prefix + originalKey, originalValue);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2853,7 +2853,7 @@
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
? new ReactPromise(
Expand Down Expand Up @@ -2963,6 +2963,10 @@
case "fulfilled":
id = chunk.value;
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."
);
for (
var localLength = 0,
rootArrayContexts = response._rootArrayContexts,
Expand Down Expand Up @@ -3054,23 +3058,20 @@
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;
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;
Expand Down Expand Up @@ -3108,7 +3109,7 @@
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++;
Expand Down Expand Up @@ -3152,7 +3153,7 @@
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 &&
Expand Down Expand Up @@ -3372,24 +3373,33 @@
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;
reference = value.keys;
null === reference &&
((reference = value.keys = Array.from(value.data.keys())),
(value.keyPointer = 0));
value = reference[value.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]);
reference = response;
reference.data.delete(value);
reference.keyPointer++;
} else if (value.startsWith(obj)) break;
else response.keyPointer++;
}
return arrayRoot;
case "i":
return (
(arrayRoot = value.slice(2)),
Expand Down Expand Up @@ -3560,7 +3570,7 @@
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]) {
Expand Down Expand Up @@ -3601,7 +3611,7 @@
return {
_bundlerConfig: bundlerConfig,
_prefix: formFieldPrefix,
_formData: backingFormData,
_formData: { data: backingFormData, keyPointer: -1, keys: null },
_chunks: chunks,
_temporaryReferences: temporaryReferences,
_rootArrayContexts: new WeakMap(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2504,6 +2504,10 @@ function getOutlinedModel(
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.

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.

);
for (
var localLength = 0,
rootArrayContexts = response._rootArrayContexts,
Expand Down Expand Up @@ -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;

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.

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.

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.

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.

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;
Expand Down Expand Up @@ -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++;
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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;

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.

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.

else response.keyPointer++;
}
return arrayRoot;
case "i":
return (
(arrayRoot = value.slice(2)),
Expand Down Expand Up @@ -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]) {
Expand Down Expand Up @@ -3107,7 +3115,7 @@ function createResponse(bundlerConfig, formFieldPrefix, temporaryReferences) {
return {
_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.

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.

_chunks: chunks,
_temporaryReferences: temporaryReferences,
_rootArrayContexts: new WeakMap(),
Expand Down
Loading
Loading