Prototype Pollution
Description
This commit fixes a real prototype-pollution vulnerability in React Flight SSR serialization. Previously, the serialization path used a regular in-place property copy (resolved[key] = value) while walking the model. If an attacker supplied data containing an own __proto__ key, this could cause prototype pollution via the prototype chain of the object being built. The patch moves to a pure-JS recursive resolveModel step and handles __proto__ specially by creating an own property on the target object via Object.defineProperty, thereby preventing prototype mutation. It also avoids the C++ boundary overhead by not using JSON.stringify’s replacer for each key.
Vulnerability type: Prototype Pollution
Affected surface: React Flight SSR serialization path (server-to-client data rendering)
Impact: If an attacker can influence the serialized data to include a crafted __proto__ payload, they could pollute the prototype of the resulting object, enabling downstream prototype pollution effects in client code that consumes the serialized data.
Proof of Concept
Proof-of-concept (pre-fix vs post-fix demonstration):
Pre-fix (vulnerable path simulation):
// Vulnerable path simulation (before fix)
function vulnerableClone(input) {
const out = {};
for (const key in input) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
// Potential prototype pollution if key === '__proto__'
out[key] = input[key];
}
}
return out;
}
// Craft payload with an own __proto__ property
const payload = {};
Object.defineProperty(payload, '__proto__', {
value: { polluted: true },
enumerable: true,
writable: true,
configurable: true
});
payload.a = 1;
const res = vulnerableClone(payload);
console.log('Pre-fix: does res.__proto__ point to the polluted object? ', res.__proto__ && res.__proto__.polluted === true);
console.log('Pre-fix: is the prototype polluted for res? ', Object.getPrototypeOf(res).polluted === true);
Post-fix (safe path after fix in this commit):
// Safe path after fix (resolveModel + defineProperty for __proto__)
function safeClone(input) {
const out = {};
for (const key in input) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
if (key === '__proto__') {
Object.defineProperty(out, key, {
value: input[key],
enumerable: true,
writable: true,
configurable: true
});
} else {
out[key] = input[key];
}
}
}
return out;
}
const payload2 = {};
Object.defineProperty(payload2, '__proto__', {
value: { polluted: true },
enumerable: true,
writable: true,
configurable: true
});
payload2.a = 1;
const res2 = safeClone(payload2);
console.log('Post-fix: own __proto__ present on res2? ', Object.prototype.hasOwnProperty.call(res2, '__proto__'));
console.log('Post-fix: res2.__proto__ is own property value? ', res2.__proto__.polluted);
console.log('Post-fix: Object.prototype polluted? ', Object.prototype.polluted !== undefined);
Commit Details
Author: Michael Hart
Date: 2026-06-16 08:01 UTC
Message:
[Flight] Resolve models before JSON.stringify (#36795)
Move the toJSON handling out of the JSON.stringify replacer path and
into an explicit recursive resolution step that uses v8's optimized
single-arg JSON.stringify call.
This has been pulled out of the original implementation in
https://github.com/react/react/pull/36053 on the advice of
https://github.com/react/react/pull/36181 (thanks @unstubbable)
### NB: \_\_proto\_\_
The only difference is surrounding the treatment of `{}` vs
`Object.create(null)`. https://github.com/react/react/pull/36053 uses
the latter, which avoids `__proto__` issues, but is slower in
microbenchmarks due to v8 semantics.
https://github.com/react/react/pull/36181 uses the former (`{}`) which
is faster in micro benchmarks but doesn't special-case `__proto__`
according to spec. This PR does both (`{}` and special case), with no
measurable performance difference that I could produce.
---
The rest of this PR's description is reproduced from
https://github.com/react/react/pull/36181:
---
### Problem
When serializing a Flight chunk, `emitChunk` currently calls
`JSON.stringify(value, task.toJSON)`. The `task.toJSON` replacer is
called for every key-value pair in the serialized JSON. While the logic
inside the replacer is lightweight, the C++ to JavaScript boundary
crossing on every node adds up — V8's `JSON.stringify` is implemented in
C++, and calling back into JavaScript for every property incurs overhead
that scales with the number of keys in the output.
### Change
Replace the replacer with a two-step process:
1. `resolveModel()` recursively walks the rendered value, calling
`renderModel()` on each child — doing the same transformation the
replacer used to do, but entirely in JavaScript without C++ boundary
crossings.
2. `JSON.stringify()` is called with no replacer, staying entirely in
C++.
The `resolveModel` walk also replicates `JSON.stringify`'s `toJSON`
semantics for `Date` objects.
### Results
Measured using the Flight SSR benchmark fixture (#36180) on a dashboard
app with ~25 components, 200 product rows (~325KB Flight payload).
Tested across Node 20, 22, and 24.
- **`bench:bare`** (in-process, no script injection): Flight+Fizz sync
median improves by **~4-5%** consistently across all three Node
versions.
- **`bench:server`** (HTTP, c=1): Flight+Fizz sync throughput improves
by **~3-6%** across Node versions. Async results vary between runs but
trend positive.
### Future opportunity
While the immediate performance improvement is moderate, this change
also sets up a potential future optimization: a Flight mode that renders
to an object instead of a stream ([#36143
(comment)](https://github.com/facebook/react/issues/36143#issuecomment-4155701790)).
Since `resolveModel()` already produces a plain JS object tree before
`JSON.stringify` is called, this intermediate representation could
potentially be passed to the SSR client without the
serialization-deserialization roundtrip that the current stream-based
approach requires.
closes #36181
Triage Assessment
Vulnerability Type: Prototype Pollution
Confidence: MEDIUM
Reasoning:
Includes changes to JSON serialization of Flight data to handle __proto__ safely and avoid prototype pollution risks. The code path now uses a dedicated resolveModel step and treats __proto__ specially to prevent prototype mutation, addressing a known security concern around prototype pollution during object serialization.
Verification Assessment
Vulnerability Type: Prototype Pollution
Confidence: MEDIUM
Affected Versions: < 19.2.4
Code Diff
diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js
index 95633283b2c7..473b86aad2a8 100644
--- a/packages/react-client/src/__tests__/ReactFlight-test.js
+++ b/packages/react-client/src/__tests__/ReactFlight-test.js
@@ -2085,6 +2085,55 @@ describe('ReactFlight', () => {
]);
});
+ it('should serialize an own __proto__ property nested among siblings without disturbing them', async () => {
+ // `__proto__` here is a real own enumerable data property (not the
+ // prototype). It sits between sibling keys and holds an object value, which
+ // is the case most likely to regress if the serializer used a plain
+ // `obj.__proto__ = value` assignment: that would hit the prototype setter,
+ // dropping the key and mutating the holder's prototype instead.
+ const value = {a: 1};
+ Object.defineProperty(value, '__proto__', {
+ value: {nested: true},
+ enumerable: true,
+ writable: true,
+ configurable: true,
+ });
+ value.b = 2;
+
+ const transport = ReactNoopFlightServer.render(value);
+ assertConsoleErrorDev([
+ 'Expected not to serialize an object with own property `__proto__`. ' +
+ 'When parsed this property will be omitted.\n' +
+ ' {a: 1, __proto__: {nested: true}, b: 2}\n' +
+ ' ^^^^^^^^^^^^^^',
+ ]);
+
+ const decoder = new TextDecoder();
+ const payload = transport
+ .map(chunk => (typeof chunk === 'string' ? chunk : decoder.decode(chunk)))
+ .join('');
+ // The legacy key is serialized as ordinary data, in source order, with its
+ // object value intact and without clobbering its sibling properties.
+ expect(payload).toContain('"a":1,"__proto__":{"nested":true},"b":2');
+
+ const model = await ReactNoopFlightClient.read(transport);
+ assertConsoleErrorDev([
+ 'Expected not to serialize an object with own property `__proto__`. ' +
+ 'When parsed this property will be omitted.\n' +
+ ' {a: 1, __proto__: {nested: true}, b: 2}\n' +
+ ' ^^^^^^^^^^^^^^\n' +
+ ' in (at **)',
+ ]);
+ // On the client the legacy key is omitted, but its siblings survive intact
+ // and the holder's prototype is untouched.
+ expect(Object.prototype.hasOwnProperty.call(model, '__proto__')).toBe(
+ false,
+ );
+ expect(Object.getPrototypeOf(model)).toBe(Object.prototype);
+ expect(model.a).toBe(1);
+ expect(model.b).toBe(2);
+ });
+
it('should NOT warn in DEV for key getters', () => {
const transport = ReactNoopFlightServer.render(<div key="a" />);
ReactNoopFlightClient.read(transport);
diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js
index 9e46915fee44..189059f0b7c3 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -530,7 +530,6 @@ type Task = {
status: 0 | 1 | 3 | 4 | 5,
model: ReactClientValue,
ping: () => void,
- toJSON: (key: string, value: ReactClientValue) => ReactJSONValue,
keyPath: ReactKey, // parent server component keys
implicitSlot: boolean, // true if the root server component of this sequence had a null key
formatContext: FormatContext, // an approximate parent context from host components
@@ -2761,55 +2760,6 @@ function createTask(
implicitSlot,
formatContext: formatContext,
ping: () => pingTask(request, task),
- toJSON: function (
- this:
- | {+[key: string | number]: ReactClientValue}
- | $ReadOnlyArray<ReactClientValue>,
- parentPropertyName: string,
- value: ReactClientValue,
- ): ReactJSONValue {
- const parent = this;
- // Make sure that `parent[parentPropertyName]` wasn't JSONified before `value` was passed to us
- if (__DEV__) {
- // $FlowFixMe[incompatible-use]
- const originalValue = parent[parentPropertyName];
- if (
- typeof originalValue === 'object' &&
- originalValue !== value &&
- !(originalValue instanceof Date)
- ) {
- // Call with the server component as the currently rendering component
- // for context.
- callWithDebugContextInDEV(request, task, () => {
- if (objectName(originalValue) !== 'Object') {
- const jsxParentType = jsxChildrenParents.get(parent);
- if (typeof jsxParentType === 'string') {
- console.error(
- '%s objects cannot be rendered as text children. Try formatting it using toString().%s',
- objectName(originalValue),
- describeObjectForErrorMessage(parent, parentPropertyName),
- );
- } else {
- console.error(
- 'Only plain objects can be passed to Client Components from Server Components. ' +
- '%s objects are not supported.%s',
- objectName(originalValue),
- describeObjectForErrorMessage(parent, parentPropertyName),
- );
- }
- } else {
- console.error(
- 'Only plain objects can be passed to Client Components from Server Components. ' +
- 'Objects with toJSON methods are not supported. Convert it manually ' +
- 'to a simple value before passing it to props.%s',
- describeObjectForErrorMessage(parent, parentPropertyName),
- );
- }
- });
- }
- }
- return renderModel(request, task, parent, parentPropertyName, value);
- },
thenableState: null,
} as Omit<
Task,
@@ -2837,6 +2787,117 @@ function createTask(
return task;
}
+function resolveModel(
+ request: Request,
+ task: Task,
+ parent:
+ | {+[key: string | number]: ReactClientValue}
+ | $ReadOnlyArray<ReactClientValue>,
+ parentPropertyName: string,
+ value: ReactClientValue,
+): ReactJSONValue {
+ // Replicate JSON.stringify's toJSON semantics: if the value has a toJSON
+ // method, call it first. In practice this only matters for Date objects whose
+ // toJSON calls toISOString. Custom toJSON objects are not supported and will
+ // trigger a DEV warning below.
+ let jsonValue: ReactClientValue = value;
+ if (
+ value !== null &&
+ typeof value === 'object' &&
+ // $FlowFixMe[method-unbinding]
+ typeof value.toJSON === 'function'
+ ) {
+ // $FlowFixMe[incompatible-use]
+ jsonValue = value.toJSON(parentPropertyName);
+ }
+
+ if (__DEV__) {
+ // $FlowFixMe[incompatible-use]
+ const originalValue = parent[parentPropertyName];
+ if (
+ typeof originalValue === 'object' &&
+ originalValue !== jsonValue &&
+ !(originalValue instanceof Date)
+ ) {
+ // Call with the server component as the currently rendering component
+ // for context.
+ callWithDebugContextInDEV(request, task, () => {
+ if (objectName(originalValue) !== 'Object') {
+ const jsxParentType = jsxChildrenParents.get(parent);
+ if (typeof jsxParentType === 'string') {
+ console.error(
+ '%s objects cannot be rendered as text children. Try formatting it using toString().%s',
+ objectName(originalValue),
+ describeObjectForErrorMessage(parent, parentPropertyName),
+ );
+ } else {
+ console.error(
+ 'Only plain objects can be passed to Client Components from Server Components. ' +
+ '%s objects are not supported.%s',
+ objectName(originalValue),
+ describeObjectForErrorMessage(parent, parentPropertyName),
+ );
+ }
+ } else {
+ console.error(
+ 'Only plain objects can be passed to Client Components from Server Components. ' +
+ 'Objects with toJSON methods are not supported. Convert it manually ' +
+ 'to a simple value before passing it to props.%s',
+ describeObjectForErrorMessage(parent, parentPropertyName),
+ );
+ }
+ });
+ }
+ }
+
+ const rendered = renderModel(
+ request,
+ task,
+ parent,
+ parentPropertyName,
+ jsonValue,
+ );
+
+ if (rendered === null || typeof rendered !== 'object') {
+ return rendered;
+ }
+
+ if (isArray(rendered)) {
+ const resolved: Array<ReactJSONValue> = [];
+ for (let i = 0; i < rendered.length; i++) {
+ resolved[i] = resolveModel(request, task, rendered, '' + i, rendered[i]);
+ }
+ return resolved;
+ }
+
+ // Use `{}` for fast properties; `__proto__` is handled below because simple
+ // assignment would hit Object.prototype's setter instead of creating a key.
+ const resolved: {[key: string]: ReactJSONValue} = {} as any;
+ for (const key in rendered) {
+ if (hasOwnProperty.call(rendered, key)) {
+ const resolvedValue = resolveModel(
+ request,
+ task,
+ rendered,
+ key,
+ rendered[key],
+ );
+ if (key === __PROTO__) {
+ // Match JSON's ordinary data-property semantics for this legacy key.
+ Object.defineProperty(resolved, key, {
+ value: resolvedValue,
+ enumerable: true,
+ writable: true,
+ configurable: true,
+ });
+ } else {
+ resolved[key] = resolvedValue;
+ }
+ }
+ }
+ return resolved;
+}
+
function serializeByValueID(id: number): string {
return '$' + id.toString(16);
}
@@ -3618,7 +3679,7 @@ function renderModelDestructive(
// TODO: Pop this. Since we currently don't have a point where we can pop the stack
// this debug information will be used for errors inside sibling properties that
// are not elements. Leading to the wrong attribution on the server. We could fix
- // that if we switch to a proper stack instead of JSON.stringify's trampoline.
+ // that if we switch to a proper stack instead of resolveModel's recursive walk.
// Attribution on the client is still correct since it has a pop.
}
@@ -5816,8 +5877,11 @@ function emitChunk(
return;
}
// For anything else we need to try to serialize it using JSON.
+ // We resolve the model tree first in pure JS to avoid the C++->JS boundary
+ // overhead of JSON.stringify's replacer callback.
+ const resolvedModel = resolveModel(request, task, {'': value}, '', value);
// $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
- const json: string = stringify(value, task.toJSON);
+ const json: string = stringify(resolvedModel);
emitModelChunk(request, task.id, json);
}
@@ -5863,7 +5927,7 @@ function retryTask(request: Request, task: Task): void {
try {
// Track the root so we know that we have to emit this object even though it
// already has an ID. This is needed because we might see this object twice
- // in the same toJSON if it is cyclic.
+ // in the same resolveModel walk if it is cyclic.
modelRoot = task.model;
if (__DEV__) {
@@ -5922,8 +5986,8 @@ function retryTask(request: Request, task: Task): void {
// This is simulating what the JSON loop would do if this was part of it.
emitChunk(request, task, resolvedModel);
} else {
- // If the value is a string, it means it's a terminal value and we already escaped it
- // We don't need to escape it again so it's not passed the toJSON replacer.
+ // If the value is a string, it means it's a terminal value and we already escaped it.
+ // We don't need to escape it again so it's not passed through resolveModel.
// $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
const json: string = stringify(resolvedModel);
emitModelChunk(request, task.id, json);