Skip to content

Commit 63d61c7

Browse files
sebmarkbagegnofflubieowoceunstubbable
committed
Add more DoS mitigations to React Flight Reply, and harden React Flight
Co-authored-by: Josh Story <josh.c.story@gmail.com> Co-authored-by: Janka Uryga <lolzatu2@gmail.com> Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
1 parent 612e371 commit 63d61c7

File tree

17 files changed

+853
-267
lines changed

17 files changed

+853
-267
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ import getComponentNameFromType from 'shared/getComponentNameFromType';
9898

9999
import {getOwnerStackByComponentInfoInDev} from 'shared/ReactComponentInfoStack';
100100

101+
import hasOwnProperty from 'shared/hasOwnProperty';
102+
101103
import {injectInternals} from './ReactFlightClientDevToolsHook';
102104

103105
import {OMITTED_PROP_ERROR} from 'shared/ReactFlightPropertyAccess';
@@ -163,6 +165,8 @@ const INITIALIZED = 'fulfilled';
163165
const ERRORED = 'rejected';
164166
const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes.
165167

168+
const __PROTO__ = '__proto__';
169+
166170
type PendingChunk<T> = {
167171
status: 'pending',
168172
value: null | Array<InitializationReference | (T => mixed)>,
@@ -1496,7 +1500,16 @@ function fulfillReference(
14961500
}
14971501
}
14981502
}
1499-
value = value[path[i]];
1503+
const name = path[i];
1504+
if (
1505+
typeof value === 'object' &&
1506+
value !== null &&
1507+
hasOwnProperty.call(value, name)
1508+
) {
1509+
value = value[name];
1510+
} else {
1511+
throw new Error('Invalid reference.');
1512+
}
15001513
}
15011514

15021515
while (
@@ -1532,7 +1545,9 @@ function fulfillReference(
15321545
}
15331546

15341547
const mappedValue = map(response, value, parentObject, key);
1535-
parentObject[key] = mappedValue;
1548+
if (key !== __PROTO__) {
1549+
parentObject[key] = mappedValue;
1550+
}
15361551

15371552
// If this is the root object for a model reference, where `handler.value`
15381553
// is a stale `null`, the resolved value can be used directly.
@@ -1799,7 +1814,9 @@ function loadServerReference<A: Iterable<any>, T>(
17991814
response._encodeFormAction,
18001815
);
18011816

1802-
parentObject[key] = resolvedValue;
1817+
if (key !== __PROTO__) {
1818+
parentObject[key] = resolvedValue;
1819+
}
18031820

18041821
// If this is the root object for a model reference, where `handler.value`
18051822
// is a stale `null`, the resolved value can be used directly.
@@ -2177,29 +2194,31 @@ function defineLazyGetter<T>(
21772194
): any {
21782195
// We don't immediately initialize it even if it's resolved.
21792196
// Instead, we wait for the getter to get accessed.
2180-
Object.defineProperty(parentObject, key, {
2181-
get: function () {
2182-
if (chunk.status === RESOLVED_MODEL) {
2183-
// If it was now resolved, then we initialize it. This may then discover
2184-
// a new set of lazy references that are then asked for eagerly in case
2185-
// we get that deep.
2186-
initializeModelChunk(chunk);
2187-
}
2188-
switch (chunk.status) {
2189-
case INITIALIZED: {
2190-
return chunk.value;
2197+
if (key !== __PROTO__) {
2198+
Object.defineProperty(parentObject, key, {
2199+
get: function () {
2200+
if (chunk.status === RESOLVED_MODEL) {
2201+
// If it was now resolved, then we initialize it. This may then discover
2202+
// a new set of lazy references that are then asked for eagerly in case
2203+
// we get that deep.
2204+
initializeModelChunk(chunk);
21912205
}
2192-
case ERRORED:
2193-
throw chunk.reason;
2194-
}
2195-
// Otherwise, we didn't have enough time to load the object before it was
2196-
// accessed or the connection closed. So we just log that it was omitted.
2197-
// TODO: We should ideally throw here to indicate a difference.
2198-
return OMITTED_PROP_ERROR;
2199-
},
2200-
enumerable: true,
2201-
configurable: false,
2202-
});
2206+
switch (chunk.status) {
2207+
case INITIALIZED: {
2208+
return chunk.value;
2209+
}
2210+
case ERRORED:
2211+
throw chunk.reason;
2212+
}
2213+
// Otherwise, we didn't have enough time to load the object before it was
2214+
// accessed or the connection closed. So we just log that it was omitted.
2215+
// TODO: We should ideally throw here to indicate a difference.
2216+
return OMITTED_PROP_ERROR;
2217+
},
2218+
enumerable: true,
2219+
configurable: false,
2220+
});
2221+
}
22032222
return null;
22042223
}
22052224

@@ -2510,14 +2529,16 @@ function parseModelString(
25102529
// In DEV mode we encode omitted objects in logs as a getter that throws
25112530
// so that when you try to access it on the client, you know why that
25122531
// happened.
2513-
Object.defineProperty(parentObject, key, {
2514-
get: function () {
2515-
// TODO: We should ideally throw here to indicate a difference.
2516-
return OMITTED_PROP_ERROR;
2517-
},
2518-
enumerable: true,
2519-
configurable: false,
2520-
});
2532+
if (key !== __PROTO__) {
2533+
Object.defineProperty(parentObject, key, {
2534+
get: function () {
2535+
// TODO: We should ideally throw here to indicate a difference.
2536+
return OMITTED_PROP_ERROR;
2537+
},
2538+
enumerable: true,
2539+
configurable: false,
2540+
});
2541+
}
25212542
return null;
25222543
}
25232544
// Fallthrough
@@ -5143,6 +5164,9 @@ function parseModel<T>(response: Response, json: UninitializedModel): T {
51435164
function createFromJSONCallback(response: Response) {
51445165
// $FlowFixMe[missing-this-annot]
51455166
return function (key: string, value: JSONValue) {
5167+
if (key === __PROTO__) {
5168+
return undefined;
5169+
}
51465170
if (typeof value === 'string') {
51475171
// We can't use .bind here because we need the "this" value.
51485172
return parseModelString(response, this, key, value);

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export type ReactServerValue =
9595

9696
type ReactServerObject = {+[key: string]: ReactServerValue};
9797

98+
const __PROTO__ = '__proto__';
99+
98100
function serializeByValueID(id: number): string {
99101
return '$' + id.toString(16);
100102
}
@@ -361,6 +363,15 @@ export function processReply(
361363
): ReactJSONValue {
362364
const parent = this;
363365

366+
if (__DEV__) {
367+
if (key === __PROTO__) {
368+
console.error(
369+
'Expected not to serialize an object with own property `__proto__`. When parsed this property will be omitted.%s',
370+
describeObjectForErrorMessage(parent, key),
371+
);
372+
}
373+
}
374+
364375
// Make sure that `parent[key]` wasn't JSONified before `value` was passed to us
365376
if (__DEV__) {
366377
// $FlowFixMe[incompatible-use]
@@ -780,6 +791,10 @@ export function processReply(
780791
if (typeof value === 'function') {
781792
const referenceClosure = knownServerReferences.get(value);
782793
if (referenceClosure !== undefined) {
794+
const existingReference = writtenObjects.get(value);
795+
if (existingReference !== undefined) {
796+
return existingReference;
797+
}
783798
const {id, bound} = referenceClosure;
784799
const referenceClosureJSON = JSON.stringify({id, bound}, resolveToJSON);
785800
if (formData === null) {
@@ -789,7 +804,10 @@ export function processReply(
789804
// The reference to this function came from the same client so we can pass it back.
790805
const refId = nextPartId++;
791806
formData.set(formFieldPrefix + refId, referenceClosureJSON);
792-
return serializeServerReferenceID(refId);
807+
const serverReferenceId = serializeServerReferenceID(refId);
808+
// Store the server reference ID for deduplication.
809+
writtenObjects.set(value, serverReferenceId);
810+
return serverReferenceId;
793811
}
794812
if (temporaryReferences !== undefined && key.indexOf(':') === -1) {
795813
// TODO: If the property name contains a colon, we don't dedupe. Escape instead.

packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,17 @@ function prerenderToNodeStream(
332332
function decodeReplyFromBusboy<T>(
333333
busboyStream: Busboy,
334334
moduleBasePath: ServerManifest,
335-
options?: {temporaryReferences?: TemporaryReferenceSet},
335+
options?: {
336+
temporaryReferences?: TemporaryReferenceSet,
337+
arraySizeLimit?: number,
338+
},
336339
): Thenable<T> {
337340
const response = createResponse(
338341
moduleBasePath,
339342
'',
340343
options ? options.temporaryReferences : undefined,
344+
undefined,
345+
options ? options.arraySizeLimit : undefined,
341346
);
342347
let pendingFiles = 0;
343348
const queuedFields: Array<string> = [];
@@ -403,7 +408,10 @@ function decodeReplyFromBusboy<T>(
403408
function decodeReply<T>(
404409
body: string | FormData,
405410
moduleBasePath: ServerManifest,
406-
options?: {temporaryReferences?: TemporaryReferenceSet},
411+
options?: {
412+
temporaryReferences?: TemporaryReferenceSet,
413+
arraySizeLimit?: number,
414+
},
407415
): Thenable<T> {
408416
if (typeof body === 'string') {
409417
const form = new FormData();
@@ -415,6 +423,7 @@ function decodeReply<T>(
415423
'',
416424
options ? options.temporaryReferences : undefined,
417425
body,
426+
options ? options.arraySizeLimit : undefined,
418427
);
419428
const root = getRoot<T>(response);
420429
close(response);

packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,10 @@ export function registerServerActions(manifest: ServerManifest) {
248248

249249
export function decodeReply<T>(
250250
body: string | FormData,
251-
options?: {temporaryReferences?: TemporaryReferenceSet},
251+
options?: {
252+
temporaryReferences?: TemporaryReferenceSet,
253+
arraySizeLimit?: number,
254+
},
252255
): Thenable<T> {
253256
if (typeof body === 'string') {
254257
const form = new FormData();
@@ -260,6 +263,7 @@ export function decodeReply<T>(
260263
'',
261264
options ? options.temporaryReferences : undefined,
262265
body,
266+
options ? options.arraySizeLimit : undefined,
263267
);
264268
const root = getRoot<T>(response);
265269
close(response);

packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,10 @@ export function registerServerActions(manifest: ServerManifest) {
253253

254254
export function decodeReply<T>(
255255
body: string | FormData,
256-
options?: {temporaryReferences?: TemporaryReferenceSet},
256+
options?: {
257+
temporaryReferences?: TemporaryReferenceSet,
258+
arraySizeLimit?: number,
259+
},
257260
): Thenable<T> {
258261
if (typeof body === 'string') {
259262
const form = new FormData();
@@ -265,6 +268,7 @@ export function decodeReply<T>(
265268
'',
266269
options ? options.temporaryReferences : undefined,
267270
body,
271+
options ? options.arraySizeLimit : undefined,
268272
);
269273
const root = getRoot<T>(response);
270274
close(response);

packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -562,12 +562,17 @@ export function registerServerActions(manifest: ServerManifest) {
562562

563563
export function decodeReplyFromBusboy<T>(
564564
busboyStream: Busboy,
565-
options?: {temporaryReferences?: TemporaryReferenceSet},
565+
options?: {
566+
temporaryReferences?: TemporaryReferenceSet,
567+
arraySizeLimit?: number,
568+
},
566569
): Thenable<T> {
567570
const response = createResponse(
568571
serverManifest,
569572
'',
570573
options ? options.temporaryReferences : undefined,
574+
undefined,
575+
options ? options.arraySizeLimit : undefined,
571576
);
572577
let pendingFiles = 0;
573578
const queuedFields: Array<string> = [];
@@ -632,7 +637,10 @@ export function decodeReplyFromBusboy<T>(
632637

633638
export function decodeReply<T>(
634639
body: string | FormData,
635-
options?: {temporaryReferences?: TemporaryReferenceSet},
640+
options?: {
641+
temporaryReferences?: TemporaryReferenceSet,
642+
arraySizeLimit?: number,
643+
},
636644
): Thenable<T> {
637645
if (typeof body === 'string') {
638646
const form = new FormData();
@@ -644,6 +652,7 @@ export function decodeReply<T>(
644652
'',
645653
options ? options.temporaryReferences : undefined,
646654
body,
655+
options ? options.arraySizeLimit : undefined,
647656
);
648657
const root = getRoot<T>(response);
649658
close(response);
@@ -652,7 +661,10 @@ export function decodeReply<T>(
652661

653662
export function decodeReplyFromAsyncIterable<T>(
654663
iterable: AsyncIterable<[string, string | File]>,
655-
options?: {temporaryReferences?: TemporaryReferenceSet},
664+
options?: {
665+
temporaryReferences?: TemporaryReferenceSet,
666+
arraySizeLimit?: number,
667+
},
656668
): Thenable<T> {
657669
const iterator: AsyncIterator<[string, string | File]> =
658670
iterable[ASYNC_ITERATOR]();
@@ -661,6 +673,8 @@ export function decodeReplyFromAsyncIterable<T>(
661673
serverManifest,
662674
'',
663675
options ? options.temporaryReferences : undefined,
676+
undefined,
677+
options ? options.arraySizeLimit : undefined,
664678
);
665679

666680
function progress(

packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,10 @@ function prerender(
242242
function decodeReply<T>(
243243
body: string | FormData,
244244
turbopackMap: ServerManifest,
245-
options?: {temporaryReferences?: TemporaryReferenceSet},
245+
options?: {
246+
temporaryReferences?: TemporaryReferenceSet,
247+
arraySizeLimit?: number,
248+
},
246249
): Thenable<T> {
247250
if (typeof body === 'string') {
248251
const form = new FormData();
@@ -254,6 +257,7 @@ function decodeReply<T>(
254257
'',
255258
options ? options.temporaryReferences : undefined,
256259
body,
260+
options ? options.arraySizeLimit : undefined,
257261
);
258262
const root = getRoot<T>(response);
259263
close(response);

packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,10 @@ function prerender(
247247
function decodeReply<T>(
248248
body: string | FormData,
249249
turbopackMap: ServerManifest,
250-
options?: {temporaryReferences?: TemporaryReferenceSet},
250+
options?: {
251+
temporaryReferences?: TemporaryReferenceSet,
252+
arraySizeLimit?: number,
253+
},
251254
): Thenable<T> {
252255
if (typeof body === 'string') {
253256
const form = new FormData();
@@ -259,6 +262,7 @@ function decodeReply<T>(
259262
'',
260263
options ? options.temporaryReferences : undefined,
261264
body,
265+
options ? options.arraySizeLimit : undefined,
262266
);
263267
const root = getRoot<T>(response);
264268
close(response);
@@ -268,7 +272,10 @@ function decodeReply<T>(
268272
function decodeReplyFromAsyncIterable<T>(
269273
iterable: AsyncIterable<[string, string | File]>,
270274
turbopackMap: ServerManifest,
271-
options?: {temporaryReferences?: TemporaryReferenceSet},
275+
options?: {
276+
temporaryReferences?: TemporaryReferenceSet,
277+
arraySizeLimit?: number,
278+
},
272279
): Thenable<T> {
273280
const iterator: AsyncIterator<[string, string | File]> =
274281
iterable[ASYNC_ITERATOR]();
@@ -277,6 +284,8 @@ function decodeReplyFromAsyncIterable<T>(
277284
turbopackMap,
278285
'',
279286
options ? options.temporaryReferences : undefined,
287+
undefined,
288+
options ? options.arraySizeLimit : undefined,
280289
);
281290

282291
function progress(

0 commit comments

Comments
 (0)