From 699abc89cea33d5aa0b443050ae27c2fea7cb3cb Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:38:56 +0000 Subject: [PATCH 1/3] [flags] make enableComponentPerformanceTrack static everywhere (#35629) Follow-up to https://github.com/facebook/react/pull/34665. Already gated on `enableProfilerTimer` everywhere, which is only enabled for `__PROFILE__`, except for Flight should be unified in a future. --- packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js | 1 - packages/shared/forks/ReactFeatureFlags.native-fb.js | 3 +-- packages/shared/forks/ReactFeatureFlags.www-dynamic.js | 1 - packages/shared/forks/ReactFeatureFlags.www.js | 3 ++- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index c1d8ea3ceaa..fe7777eda71 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -25,4 +25,3 @@ export const passChildrenWhenCloningPersistedNodes = __VARIANT__; export const enableFragmentRefs = __VARIANT__; export const enableFragmentRefsScrollIntoView = __VARIANT__; export const enableFragmentRefsInstanceHandles = __VARIANT__; -export const enableComponentPerformanceTrack = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index dab30e7f115..d516581486e 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -77,8 +77,7 @@ export const enableSrcObject: boolean = false; export const enableHydrationChangeEvent: boolean = true; export const enableDefaultTransitionIndicator: boolean = true; export const ownerStackLimit = 1e4; -export const enableComponentPerformanceTrack: boolean = - __PROFILE__ && dynamicFlags.enableComponentPerformanceTrack; +export const enableComponentPerformanceTrack: boolean = true; export const enablePerformanceIssueReporting: boolean = enableComponentPerformanceTrack; export const enableInternalInstanceMap: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index c5a0cf63fd6..a8d829ee3e3 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -31,7 +31,6 @@ export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__; export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__; export const enableViewTransition: boolean = __VARIANT__; -export const enableComponentPerformanceTrack: boolean = __VARIANT__; export const enableScrollEndPolyfill: boolean = __VARIANT__; export const enableFragmentRefs: boolean = __VARIANT__; export const enableFragmentRefsScrollIntoView: boolean = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index bc64b65053b..8a710cc429d 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -29,7 +29,6 @@ export const { syncLaneExpirationMs, transitionLaneExpirationMs, enableViewTransition, - enableComponentPerformanceTrack, enableScrollEndPolyfill, enableFragmentRefs, enableFragmentRefsScrollIntoView, @@ -56,6 +55,8 @@ export const enableYieldingBeforePassive: boolean = false; export const enableThrottledScheduling: boolean = false; +export const enableComponentPerformanceTrack: boolean = true; + export const enablePerformanceIssueReporting: boolean = false; // Logs additional User Timing API marks for use with an experimental profiling tool. From 10680271fab565e0edf948d3a6dc9d30e83df94c Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 26 Jan 2026 20:24:58 +0100 Subject: [PATCH 2/3] [Flight] Add more DoS mitigations to Flight Reply, and harden Flight (#35632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fixes security vulnerabilities in Server Functions. --------- Co-authored-by: Sebastian Markbåge Co-authored-by: Josh Story Co-authored-by: Janka Uryga Co-authored-by: Sebastian Sebbie Silbermann --- .../react-client/src/ReactFlightClient.js | 90 ++- .../src/ReactFlightReplyClient.js | 20 +- .../forks/ReactFlightClientConfig.markup.js | 2 +- .../src/server/ReactFlightDOMServerNode.js | 13 +- .../src/server/ReactFlightDOMServerBrowser.js | 6 +- .../src/server/ReactFlightDOMServerEdge.js | 6 +- .../src/server/ReactFlightDOMServerNode.js | 20 +- .../src/server/ReactFlightDOMServerBrowser.js | 6 +- .../src/server/ReactFlightDOMServerEdge.js | 13 +- .../src/server/ReactFlightDOMServerNode.js | 20 +- .../src/server/ReactFlightDOMServerNode.js | 20 +- .../src/server/ReactFlightDOMServerBrowser.js | 6 +- .../src/server/ReactFlightDOMServerEdge.js | 13 +- .../src/server/ReactFlightDOMServerNode.js | 20 +- .../src/ReactFlightActionServer.js | 58 +- .../src/ReactFlightReplyServer.js | 759 +++++++++++++----- .../react-server/src/ReactFlightServer.js | 13 + scripts/error-codes/codes.json | 13 +- 18 files changed, 835 insertions(+), 263 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 0db63ecd3ef..2246f74697e 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -94,6 +94,8 @@ import getComponentNameFromType from 'shared/getComponentNameFromType'; import {getOwnerStackByComponentInfoInDev} from 'shared/ReactComponentInfoStack'; +import hasOwnProperty from 'shared/hasOwnProperty'; + import {injectInternals} from './ReactFlightClientDevToolsHook'; import {OMITTED_PROP_ERROR} from 'shared/ReactFlightPropertyAccess'; @@ -159,6 +161,8 @@ const INITIALIZED = 'fulfilled'; const ERRORED = 'rejected'; const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes. +const __PROTO__ = '__proto__'; + type PendingChunk = { status: 'pending', value: null | Array mixed)>, @@ -1544,7 +1548,16 @@ function fulfillReference( } } } - value = value[path[i]]; + const name = path[i]; + if ( + typeof value === 'object' && + value !== null && + hasOwnProperty.call(value, name) + ) { + value = value[name]; + } else { + throw new Error('Invalid reference.'); + } } while ( @@ -1580,7 +1593,9 @@ function fulfillReference( } const mappedValue = map(response, value, parentObject, key); - parentObject[key] = mappedValue; + if (key !== __PROTO__) { + parentObject[key] = mappedValue; + } // If this is the root object for a model reference, where `handler.value` // is a stale `null`, the resolved value can be used directly. @@ -1849,7 +1864,9 @@ function loadServerReference, T>( response._encodeFormAction, ); - parentObject[key] = resolvedValue; + if (key !== __PROTO__) { + parentObject[key] = resolvedValue; + } // If this is the root object for a model reference, where `handler.value` // is a stale `null`, the resolved value can be used directly. @@ -2231,29 +2248,31 @@ function defineLazyGetter( ): any { // We don't immediately initialize it even if it's resolved. // Instead, we wait for the getter to get accessed. - Object.defineProperty(parentObject, key, { - get: function () { - if (chunk.status === RESOLVED_MODEL) { - // If it was now resolved, then we initialize it. This may then discover - // a new set of lazy references that are then asked for eagerly in case - // we get that deep. - initializeModelChunk(chunk); - } - switch (chunk.status) { - case INITIALIZED: { - return chunk.value; + if (key !== __PROTO__) { + Object.defineProperty(parentObject, key, { + get: function () { + if (chunk.status === RESOLVED_MODEL) { + // If it was now resolved, then we initialize it. This may then discover + // a new set of lazy references that are then asked for eagerly in case + // we get that deep. + initializeModelChunk(chunk); } - case ERRORED: - throw chunk.reason; - } - // Otherwise, we didn't have enough time to load the object before it was - // accessed or the connection closed. So we just log that it was omitted. - // TODO: We should ideally throw here to indicate a difference. - return OMITTED_PROP_ERROR; - }, - enumerable: true, - configurable: false, - }); + switch (chunk.status) { + case INITIALIZED: { + return chunk.value; + } + case ERRORED: + throw chunk.reason; + } + // Otherwise, we didn't have enough time to load the object before it was + // accessed or the connection closed. So we just log that it was omitted. + // TODO: We should ideally throw here to indicate a difference. + return OMITTED_PROP_ERROR; + }, + enumerable: true, + configurable: false, + }); + } return null; } @@ -2564,14 +2583,16 @@ function parseModelString( // In DEV mode we encode omitted objects in logs as a getter that throws // so that when you try to access it on the client, you know why that // happened. - Object.defineProperty(parentObject, key, { - get: function () { - // TODO: We should ideally throw here to indicate a difference. - return OMITTED_PROP_ERROR; - }, - enumerable: true, - configurable: false, - }); + if (key !== __PROTO__) { + Object.defineProperty(parentObject, key, { + get: function () { + // TODO: We should ideally throw here to indicate a difference. + return OMITTED_PROP_ERROR; + }, + enumerable: true, + configurable: false, + }); + } return null; } // Fallthrough @@ -5183,6 +5204,9 @@ function parseModel(response: Response, json: UninitializedModel): T { function createFromJSONCallback(response: Response) { // $FlowFixMe[missing-this-annot] return function (key: string, value: JSONValue) { + if (key === __PROTO__) { + return undefined; + } if (typeof value === 'string') { // We can't use .bind here because we need the "this" value. return parseModelString(response, this, key, value); diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 4dc13ce4860..9a1b6651868 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -95,6 +95,8 @@ export type ReactServerValue = type ReactServerObject = {+[key: string]: ReactServerValue}; +const __PROTO__ = '__proto__'; + function serializeByValueID(id: number): string { return '$' + id.toString(16); } @@ -361,6 +363,15 @@ export function processReply( ): ReactJSONValue { const parent = this; + if (__DEV__) { + if (key === __PROTO__) { + console.error( + 'Expected not to serialize an object with own property `__proto__`. When parsed this property will be omitted.%s', + describeObjectForErrorMessage(parent, key), + ); + } + } + // Make sure that `parent[key]` wasn't JSONified before `value` was passed to us if (__DEV__) { // $FlowFixMe[incompatible-use] @@ -780,6 +791,10 @@ export function processReply( if (typeof value === 'function') { const referenceClosure = knownServerReferences.get(value); if (referenceClosure !== undefined) { + const existingReference = writtenObjects.get(value); + if (existingReference !== undefined) { + return existingReference; + } const {id, bound} = referenceClosure; const referenceClosureJSON = JSON.stringify({id, bound}, resolveToJSON); if (formData === null) { @@ -789,7 +804,10 @@ export function processReply( // The reference to this function came from the same client so we can pass it back. const refId = nextPartId++; formData.set(formFieldPrefix + refId, referenceClosureJSON); - return serializeServerReferenceID(refId); + const serverReferenceId = serializeServerReferenceID(refId); + // Store the server reference ID for deduplication. + writtenObjects.set(value, serverReferenceId); + return serverReferenceId; } if (temporaryReferences !== undefined && key.indexOf(':') === -1) { // TODO: If the property name contains a colon, we don't dedupe. Escape instead. diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js index fcd67245044..76a973b377b 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js @@ -43,7 +43,7 @@ export function resolveClientReference( export function resolveServerReference( config: ServerManifest, - id: ServerReferenceId, + id: mixed, ): ClientReference { throw new Error( 'renderToHTML should not have emitted Server References. This is a bug in React.', diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index b81e177d642..8e0799fb020 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -328,12 +328,17 @@ function prerenderToNodeStream( function decodeReplyFromBusboy( busboyStream: Busboy, moduleBasePath: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const response = createResponse( moduleBasePath, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); let pendingFiles = 0; const queuedFields: Array = []; @@ -399,7 +404,10 @@ function decodeReplyFromBusboy( function decodeReply( body: string | FormData, moduleBasePath: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -411,6 +419,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js index 31c3e5cd8b6..bdaadd66684 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js @@ -245,7 +245,10 @@ export function registerServerActions(manifest: ServerManifest) { export function decodeReply( body: string | FormData, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -257,6 +260,7 @@ export function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js index 63218160d8d..83150996ae6 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js @@ -250,7 +250,10 @@ export function registerServerActions(manifest: ServerManifest) { export function decodeReply( body: string | FormData, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -262,6 +265,7 @@ export function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index 294e99e502a..c5903c41ed4 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -556,12 +556,17 @@ export function registerServerActions(manifest: ServerManifest) { export function decodeReplyFromBusboy( busboyStream: Busboy, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const response = createResponse( serverManifest, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); let pendingFiles = 0; const queuedFields: Array = []; @@ -626,7 +631,10 @@ export function decodeReplyFromBusboy( export function decodeReply( body: string | FormData, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -638,6 +646,7 @@ export function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -646,7 +655,10 @@ export function decodeReply( export function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -655,6 +667,8 @@ export function decodeReplyFromAsyncIterable( serverManifest, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js index 7ad138e62f5..9388e5790f1 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js @@ -239,7 +239,10 @@ function prerender( function decodeReply( body: string | FormData, turbopackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -251,6 +254,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js index 52b4468dcd3..f6a8fcc9abc 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js @@ -244,7 +244,10 @@ function prerender( function decodeReply( body: string | FormData, turbopackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -256,6 +259,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -265,7 +269,10 @@ function decodeReply( function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, turbopackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -274,6 +281,8 @@ function decodeReplyFromAsyncIterable( turbopackMap, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 2f4301d1120..74d379f53a0 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -548,12 +548,17 @@ function prerender( function decodeReplyFromBusboy( busboyStream: Busboy, turbopackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const response = createResponse( turbopackMap, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); let pendingFiles = 0; const queuedFields: Array = []; @@ -619,7 +624,10 @@ function decodeReplyFromBusboy( function decodeReply( body: string | FormData, turbopackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -631,6 +639,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -640,7 +649,10 @@ function decodeReply( function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, turbopackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -649,6 +661,8 @@ function decodeReplyFromAsyncIterable( turbopackMap, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( diff --git a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js index 647498a65d8..9a75c20395b 100644 --- a/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-unbundled/src/server/ReactFlightDOMServerNode.js @@ -548,12 +548,17 @@ function prerender( function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const response = createResponse( webpackMap, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); let pendingFiles = 0; const queuedFields: Array = []; @@ -619,7 +624,10 @@ function decodeReplyFromBusboy( function decodeReply( body: string | FormData, webpackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -631,6 +639,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -640,7 +649,10 @@ function decodeReply( function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, webpackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -649,6 +661,8 @@ function decodeReplyFromAsyncIterable( webpackMap, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js index 08192fd1e52..1c417ff6bda 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js @@ -239,7 +239,10 @@ function prerender( function decodeReply( body: string | FormData, webpackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -251,6 +254,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js index 73fc74d4fa4..77067754bc5 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js @@ -244,7 +244,10 @@ function prerender( function decodeReply( body: string | FormData, webpackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -256,6 +259,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -265,7 +269,10 @@ function decodeReply( function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, webpackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -274,6 +281,8 @@ function decodeReplyFromAsyncIterable( webpackMap, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index 5e73d8eb3a5..888d0139144 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -548,12 +548,17 @@ function prerender( function decodeReplyFromBusboy( busboyStream: Busboy, webpackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const response = createResponse( webpackMap, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); let pendingFiles = 0; const queuedFields: Array = []; @@ -619,7 +624,10 @@ function decodeReplyFromBusboy( function decodeReply( body: string | FormData, webpackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { if (typeof body === 'string') { const form = new FormData(); @@ -631,6 +639,7 @@ function decodeReply( '', options ? options.temporaryReferences : undefined, body, + options ? options.arraySizeLimit : undefined, ); const root = getRoot(response); close(response); @@ -640,7 +649,10 @@ function decodeReply( function decodeReplyFromAsyncIterable( iterable: AsyncIterable<[string, string | File]>, webpackMap: ServerManifest, - options?: {temporaryReferences?: TemporaryReferenceSet}, + options?: { + temporaryReferences?: TemporaryReferenceSet, + arraySizeLimit?: number, + }, ): Thenable { const iterator: AsyncIterator<[string, string | File]> = iterable[ASYNC_ITERATOR](); @@ -649,6 +661,8 @@ function decodeReplyFromAsyncIterable( webpackMap, '', options ? options.temporaryReferences : undefined, + undefined, + options ? options.arraySizeLimit : undefined, ); function progress( diff --git a/packages/react-server/src/ReactFlightActionServer.js b/packages/react-server/src/ReactFlightActionServer.js index 9062a6c03da..ddcf36e96f9 100644 --- a/packages/react-server/src/ReactFlightActionServer.js +++ b/packages/react-server/src/ReactFlightActionServer.js @@ -7,7 +7,7 @@ * @flow */ -import type {Thenable, ReactFormState} from 'shared/ReactTypes'; +import type {ReactFormState} from 'shared/ReactTypes'; import type { ServerManifest, @@ -20,26 +20,48 @@ import { requireModule, } from 'react-client/src/ReactFlightClientConfig'; -import {createResponse, close, getRoot} from './ReactFlightReplyServer'; +import { + createResponse, + close, + getRoot, + MAX_BOUND_ARGS, +} from './ReactFlightReplyServer'; type ServerReferenceId = any; function bindArgs(fn: any, args: any) { + if (args.length > MAX_BOUND_ARGS) { + throw new Error( + 'Server Function has too many bound arguments. Received ' + + args.length + + ' but the limit is ' + + MAX_BOUND_ARGS + + '.', + ); + } + return fn.bind.apply(fn, [null].concat(args)); } function loadServerReference( bundlerConfig: ServerManifest, - id: ServerReferenceId, - bound: null | Thenable>, + metaData: { + id: string, + bound: null | Promise>, + }, ): Promise { + const id: ServerReferenceId = metaData.id; + if (typeof id !== 'string') { + return (null: any); + } const serverReference: ServerReference = resolveServerReference<$FlowFixMe>(bundlerConfig, id); // We expect most servers to not really need this because you'd just have all // the relevant modules already loaded but it allows for lazy loading of code // if needed. const preloadPromise = preloadModule(serverReference); - if (bound) { + const bound = metaData.bound; + if (bound instanceof Promise) { return Promise.all([(bound: any), preloadPromise]).then( ([args]: Array) => bindArgs(requireModule(serverReference), args), ); @@ -57,6 +79,7 @@ function decodeBoundActionMetaData( body: FormData, serverManifest: ServerManifest, formFieldPrefix: string, + arraySizeLimit: void | number, ): {id: ServerReferenceId, bound: null | Promise>} { // The data for this reference is encoded in multiple fields under this prefix. const actionResponse = createResponse( @@ -64,6 +87,7 @@ function decodeBoundActionMetaData( formFieldPrefix, undefined, body, + arraySizeLimit, ); close(actionResponse); const refPromise = getRoot<{ @@ -89,6 +113,7 @@ export function decodeAction( const formData = new FormData(); let action: Promise<(formData: FormData) => T> | null = null; + const seenActions = new Set(); // $FlowFixMe[prop-missing] body.forEach((value: string | File, key: string) => { @@ -97,21 +122,36 @@ export function decodeAction( formData.append(key, value); return; } - // Later actions may override earlier actions if a button is used to override the default - // form action. + // Later actions may override earlier actions if a button is used to + // override the default form action. However, we don't expect the same + // action ref field to be sent multiple times in legitimate form data. if (key.startsWith('$ACTION_REF_')) { + if (seenActions.has(key)) { + return; + } + seenActions.add(key); const formFieldPrefix = '$ACTION_' + key.slice(12) + ':'; const metaData = decodeBoundActionMetaData( body, serverManifest, formFieldPrefix, ); - action = loadServerReference(serverManifest, metaData.id, metaData.bound); + action = loadServerReference(serverManifest, metaData); return; } + // A simple action with no bound arguments may appear twice in the form data + // if a button specifies the same action as the default form action. We only + // load the first one, as they're guaranteed to be identical. if (key.startsWith('$ACTION_ID_')) { + if (seenActions.has(key)) { + return; + } + seenActions.add(key); const id = key.slice(11); - action = loadServerReference(serverManifest, id, null); + action = loadServerReference(serverManifest, { + id, + bound: null, + }); return; } }); diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 88781cc6eb8..d3eff13ff46 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -34,6 +34,7 @@ import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; import hasOwnProperty from 'shared/hasOwnProperty'; import getPrototypeOf from 'shared/getPrototypeOf'; +import isArray from 'shared/isArray'; interface FlightStreamController { enqueueModel(json: string): void; @@ -55,6 +56,8 @@ const RESOLVED_MODEL = 'resolved_model'; const INITIALIZED = 'fulfilled'; const ERRORED = 'rejected'; +const __PROTO__ = '__proto__'; + type RESPONSE_SYMBOL_TYPE = 'RESPONSE_SYMBOL'; // Fake symbol type. const RESPONSE_SYMBOL: RESPONSE_SYMBOL_TYPE = (Symbol(): any); @@ -79,7 +82,7 @@ type ResolvedModelChunk = { type InitializedChunk = { status: 'fulfilled', value: T, - reason: null, + reason: null | NestedArrayContext, then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; type InitializedStreamChunk< @@ -194,6 +197,8 @@ export type Response = { _closed: boolean, _closedReason: mixed, _temporaryReferences: void | TemporaryReferenceSet, + _rootArrayContexts: WeakMap<$ReadOnlyArray, NestedArrayContext>, + _arraySizeLimit: number, }; export function getRoot(response: Response): Thenable { @@ -210,13 +215,14 @@ function wakeChunk( response: Response, listeners: Array mixed)>, value: T, + chunk: InitializedChunk, ): void { for (let i = 0; i < listeners.length; i++) { const listener = listeners[i]; if (typeof listener === 'function') { listener(value); } else { - fulfillReference(response, listener, value); + fulfillReference(response, listener, value, chunk.reason); } } } @@ -236,33 +242,6 @@ function rejectChunk( } } -function resolveBlockedCycle( - resolvedChunk: SomeChunk, - reference: InitializationReference, -): null | InitializationHandler { - const referencedChunk = reference.handler.chunk; - if (referencedChunk === null) { - return null; - } - if (referencedChunk === resolvedChunk) { - // We found the cycle. We can resolve the blocked cycle now. - return reference.handler; - } - const resolveListeners = referencedChunk.value; - if (resolveListeners !== null) { - for (let i = 0; i < resolveListeners.length; i++) { - const listener = resolveListeners[i]; - if (typeof listener !== 'function') { - const foundHandler = resolveBlockedCycle(resolvedChunk, listener); - if (foundHandler !== null) { - return foundHandler; - } - } - } - } - return null; -} - function wakeChunkIfInitialized( response: Response, chunk: SomeChunk, @@ -271,45 +250,9 @@ function wakeChunkIfInitialized( ): void { switch (chunk.status) { case INITIALIZED: - wakeChunk(response, resolveListeners, chunk.value); + wakeChunk(response, resolveListeners, chunk.value, chunk); break; case BLOCKED: - // It is possible that we're blocked on our own chunk if it's a cycle. - // Before adding back the listeners to the chunk, let's check if it would - // result in a cycle. - for (let i = 0; i < resolveListeners.length; i++) { - const listener = resolveListeners[i]; - if (typeof listener !== 'function') { - const reference: InitializationReference = listener; - const cyclicHandler = resolveBlockedCycle(chunk, reference); - if (cyclicHandler !== null) { - // This reference points back to this chunk. We can resolve the cycle by - // using the value from that handler. - fulfillReference(response, reference, cyclicHandler.value); - resolveListeners.splice(i, 1); - i--; - if (rejectListeners !== null) { - const rejectionIdx = rejectListeners.indexOf(reference); - if (rejectionIdx !== -1) { - rejectListeners.splice(rejectionIdx, 1); - } - } - // The status might have changed after fulfilling the reference. - switch ((chunk: SomeChunk).status) { - case INITIALIZED: - const initializedChunk: InitializedChunk = (chunk: any); - wakeChunk(response, resolveListeners, initializedChunk.value); - return; - case ERRORED: - if (rejectListeners !== null) { - rejectChunk(response, rejectListeners, chunk.reason); - } - return; - } - } - } - } - // Fallthrough case PENDING: if (chunk.value) { for (let i = 0; i < resolveListeners.length; i++) { @@ -331,7 +274,7 @@ function wakeChunkIfInitialized( break; case ERRORED: if (rejectListeners) { - wakeChunk(response, rejectListeners, chunk.reason); + rejectChunk(response, rejectListeners, chunk.reason); } break; } @@ -472,22 +415,73 @@ function loadServerReference, T>( // as "thenable" which reduces to ReactPromise with no other fields. return (null: any); } + + // Check for a cached promise from a previous call with the same metadata. + // This handles deduplication when the same server reference appears multiple + // times in the payload. + const cachedPromise: SomeChunk | void = (metaData: any).$$promise; + if (cachedPromise !== undefined) { + if (cachedPromise.status === INITIALIZED) { + // The value was already resolved by a previous call. + const resolvedValue: T = cachedPromise.value; + if (key === __PROTO__) { + return (null: any); + } + parentObject[key] = resolvedValue; + return (resolvedValue: any); + } + + // The promise is still blocked. Increment the handler dependency count ... + let handler: InitializationHandler; + if (initializingHandler) { + handler = initializingHandler; + handler.deps++; + } else { + handler = initializingHandler = { + chunk: null, + value: null, + reason: null, + deps: 1, + errored: false, + }; + } + // ... and register resolve and reject listeners on the promise. + cachedPromise.then( + resolveReference.bind(null, response, handler, parentObject, key), + rejectReference.bind(null, response, handler), + ); + + // Return a place holder value for now. + return (null: any); + } + + // This is the first call for this server reference metadata. Create a cached + // promise to be used for subsequent calls. + // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors + const blockedPromise: BlockedChunk = new ReactPromise(BLOCKED, null, null); + (metaData: any).$$promise = blockedPromise; + const serverReference: ServerReference = resolveServerReference<$FlowFixMe>(response._bundlerConfig, id); // We expect most servers to not really need this because you'd just have all // the relevant modules already loaded but it allows for lazy loading of code // if needed. const bound = metaData.bound; - let promise: null | Thenable = preloadModule(serverReference); - if (!promise) { + let serverReferencePromise: null | Thenable = + preloadModule(serverReference); + if (!serverReferencePromise) { if (bound instanceof ReactPromise) { - promise = Promise.resolve(bound); + serverReferencePromise = Promise.resolve(bound); } else { const resolvedValue = (requireModule(serverReference): any); + // Resolve the cached promise synchronously. + const initializedPromise: InitializedChunk = (blockedPromise: any); + initializedPromise.status = INITIALIZED; + initializedPromise.value = resolvedValue; return resolvedValue; } } else if (bound instanceof ReactPromise) { - promise = Promise.all([promise, bound]); + serverReferencePromise = Promise.all([serverReferencePromise, bound]); } let handler: InitializationHandler; @@ -508,59 +502,59 @@ function loadServerReference, T>( let resolvedValue = (requireModule(serverReference): any); if (metaData.bound) { - // This promise is coming from us and should have initilialized by now. + // This promise is coming from us and should have initialized by now. const promiseValue = (metaData.bound: any).value; - const boundArgs: Array = Array.isArray(promiseValue) + const boundArgs: Array = isArray(promiseValue) ? promiseValue.slice(0) : []; + if (boundArgs.length > MAX_BOUND_ARGS) { + reject( + new Error( + 'Server Function has too many bound arguments. Received ' + + boundArgs.length + + ' but the limit is ' + + MAX_BOUND_ARGS + + '.', + ), + ); + return; + } boundArgs.unshift(null); // this resolvedValue = resolvedValue.bind.apply(resolvedValue, boundArgs); } - parentObject[key] = resolvedValue; - - // If this is the root object for a model reference, where `handler.value` - // is a stale `null`, the resolved value can be used directly. - if (key === '' && handler.value === null) { - handler.value = resolvedValue; + // Resolve the cached promise so subsequent references can use the value. + const resolveListeners = blockedPromise.value; + const initializedPromise: InitializedChunk = (blockedPromise: any); + initializedPromise.status = INITIALIZED; + initializedPromise.value = resolvedValue; + initializedPromise.reason = null; + if (resolveListeners !== null) { + // Notify any resolve listeners that were added via .then() from + // subsequent loadServerReference calls for the same reference. + wakeChunk(response, resolveListeners, resolvedValue, initializedPromise); } - handler.deps--; - - if (handler.deps === 0) { - const chunk = handler.chunk; - if (chunk === null || chunk.status !== BLOCKED) { - return; - } - const resolveListeners = chunk.value; - const initializedChunk: InitializedChunk = (chunk: any); - initializedChunk.status = INITIALIZED; - initializedChunk.value = handler.value; - initializedChunk.reason = null; - if (resolveListeners !== null) { - wakeChunk(response, resolveListeners, handler.value); - } - } + resolveReference(response, handler, parentObject, key, resolvedValue); } function reject(error: mixed): void { - if (handler.errored) { - // We've already errored. We could instead build up an AggregateError - // but if there are multiple errors we just take the first one like - // Promise.all. - return; - } - handler.errored = true; - handler.value = null; - handler.reason = error; - const chunk = handler.chunk; - if (chunk === null || chunk.status !== BLOCKED) { - return; + // Mark the cached promise as errored so subsequent references fail too. + const rejectListeners = blockedPromise.reason; + const erroredPromise: ErroredChunk = (blockedPromise: any); + erroredPromise.status = ERRORED; + erroredPromise.value = null; + erroredPromise.reason = error; + if (rejectListeners !== null) { + // Notify any reject listeners that were added via .then() from subsequent + // loadServerReference calls for the same reference. + rejectChunk(response, rejectListeners, error); } - triggerErrorOnChunk(response, chunk, error); + + rejectReference(response, handler, error); } - promise.then(fulfill, reject); + serverReferencePromise.then(fulfill, reject); // Return a place holder value for now. return (null: any); @@ -572,10 +566,18 @@ function reviveModel( parentKey: string, value: JSONValue, reference: void | string, + arrayRoot: null | NestedArrayContext, ): any { if (typeof value === 'string') { // We can't use .bind here because we need the "this" value. - return parseModelString(response, parentObj, parentKey, value, reference); + return parseModelString( + response, + parentObj, + parentKey, + value, + reference, + arrayRoot, + ); } if (typeof value === 'object' && value !== null) { if ( @@ -589,16 +591,42 @@ function reviveModel( reference, ); } - if (Array.isArray(value)) { + if (isArray(value)) { + let childContext: NestedArrayContext; + if (arrayRoot === null) { + childContext = ({ + count: 0, + fork: false, + }: NestedArrayContext); + response._rootArrayContexts.set(value, childContext); + } else { + childContext = arrayRoot; + } + if (value.length > 1) { + childContext.fork = true; + } + bumpArrayCount(childContext, value.length + 1, response); for (let i = 0; i < value.length; i++) { const childRef = reference !== undefined ? reference + ':' + i : undefined; // $FlowFixMe[cannot-write] - value[i] = reviveModel(response, value, '' + i, value[i], childRef); + value[i] = reviveModel( + response, + value, + '' + i, + value[i], + childRef, + childContext, + ); } } else { for (const key in value) { if (hasOwnProperty.call(value, key)) { + if (key === __PROTO__) { + // $FlowFixMe[cannot-write] + delete value[key]; + continue; + } const childRef = reference !== undefined && key.indexOf(':') === -1 ? reference + ':' + key @@ -609,8 +637,9 @@ function reviveModel( key, value[key], childRef, + null, // The array context resets when we're entering a non-array ); - if (newValue !== undefined || key === '__proto__') { + if (newValue !== undefined) { // $FlowFixMe[cannot-write] value[key] = newValue; } else { @@ -624,6 +653,27 @@ function reviveModel( return value; } +type NestedArrayContext = { + // Keeps track of how many slots, bytes or characters are in nested arrays/strings/typed arrays. + count: number, + // A single child is itself not harmful. There needs to be at least one parent array with more + // than one child. + fork: boolean, +}; + +function bumpArrayCount( + arrayContext: NestedArrayContext, + slots: number, + response: Response, +): void { + const newCount = (arrayContext.count += slots); + if (newCount > response._arraySizeLimit && arrayContext.fork) { + throw new Error( + 'Maximum array nesting exceeded. Large nested arrays can be dangerous. Try adding intermediate objects.', + ); + } +} + type InitializationReference = { handler: InitializationHandler, parentObject: Object, @@ -635,6 +685,7 @@ type InitializationReference = { key: string, ) => any, path: Array, + arrayRoot: null | NestedArrayContext, }; type InitializationHandler = { chunk: null | BlockedChunk, @@ -666,12 +717,19 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { try { const rawModel = JSON.parse(resolvedModel); + // The root might not be an array but if it is we want to track the count of entries. + const arrayRoot: NestedArrayContext = { + count: 0, + fork: false, + }; + const value: T = reviveModel( response, {'': rawModel}, '', rawModel, rootReference, + arrayRoot, ); // Invoke any listeners added while resolving this model. I.e. cyclic @@ -686,7 +744,7 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { if (typeof listener === 'function') { listener(value); } else { - fulfillReference(response, listener, value); + fulfillReference(response, listener, value, arrayRoot); } } } @@ -698,6 +756,7 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { // We discovered new dependencies on modules that are not yet resolved. // We have to keep the BLOCKED state until they're resolved. initializingHandler.value = value; + initializingHandler.reason = arrayRoot; initializingHandler.chunk = cyclicChunk; return; } @@ -705,7 +764,7 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = value; - initializedChunk.reason = null; + initializedChunk.reason = arrayRoot; } catch (error) { const erroredChunk: ErroredChunk = (chunk: any); erroredChunk.status = ERRORED; @@ -727,7 +786,11 @@ export function reportGlobalError(response: Response, error: Error): void { if (chunk.status === PENDING) { triggerErrorOnChunk(response, chunk, error); } else if (chunk.status === INITIALIZED && chunk.reason !== null) { - chunk.reason.error(error); + const maybeController = chunk.reason; + // $FlowFixMe + if (typeof maybeController.error === 'function') { + maybeController.error(error); + } } }); } @@ -759,10 +822,14 @@ function fulfillReference( response: Response, reference: InitializationReference, value: any, + arrayRoot: null | NestedArrayContext, ): void { const {handler, parentObject, key, map, path} = reference; + let resolvedValue; try { + let localLength: number = 0; + const rootArrayContexts = response._rootArrayContexts; for (let i = 1; i < path.length; i++) { // The server doesn't have any lazy references so we don't expect to go through a Promise. const name = path[i]; @@ -774,26 +841,77 @@ function fulfillReference( hasOwnProperty.call(value, name) ) { value = value[name]; + if (isArray(value)) { + localLength = 0; + arrayRoot = rootArrayContexts.get(value) || arrayRoot; + } else { + arrayRoot = null; + if (typeof value === 'string') { + localLength = value.length; + } else if (typeof value === 'bigint') { + // Estimate the length to avoid expensive toString() calls on large + // BigInt values. If the value is too large, we get Infinity, which + // will trigger the array size limit error. + // eslint-disable-next-line react-internal/no-primitive-constructors + const n = Math.abs(Number(value)); + if (n === 0) { + localLength = 1; + } else { + localLength = Math.floor(Math.log10(n)) + 1; + } + } else if (ArrayBuffer.isView(value)) { + localLength = value.byteLength; + } else { + localLength = 0; + } + } } else { throw new Error('Invalid reference.'); } } - const mappedValue = map(response, value, parentObject, key); - parentObject[key] = mappedValue; + resolvedValue = map(response, value, parentObject, key); - // If this is the root object for a model reference, where `handler.value` - // is a stale `null`, the resolved value can be used directly. - if (key === '' && handler.value === null) { - handler.value = mappedValue; + // Add any array counts to the reference's array root. The value that we're + // resolving might have deep nesting that we need to resolve. + const referenceArrayRoot = reference.arrayRoot; + if (referenceArrayRoot !== null) { + if (arrayRoot !== null) { + if (arrayRoot.fork) { + referenceArrayRoot.fork = true; + } + bumpArrayCount(referenceArrayRoot, arrayRoot.count, response); + } else if (localLength > 0) { + bumpArrayCount(referenceArrayRoot, localLength, response); + } } } catch (error) { - rejectReference(response, reference.handler, error); + rejectReference(response, handler, error); return; } // There are no Elements or Debug Info to transfer here. + resolveReference(response, handler, parentObject, key, resolvedValue); +} + +function resolveReference( + response: Response, + handler: InitializationHandler, + parentObject: Object, + key: string, + resolvedValue: mixed, +): void { + if (key !== __PROTO__) { + parentObject[key] = resolvedValue; + } + + // If this is the root object for a model reference, where `handler.value` + // is a stale `null`, the resolved value can be used directly. + if (key === '' && handler.value === null) { + handler.value = resolvedValue; + } + handler.deps--; if (handler.deps === 0) { @@ -807,7 +925,7 @@ function fulfillReference( initializedChunk.value = handler.value; initializedChunk.reason = handler.reason; // Used by streaming chunks if (resolveListeners !== null) { - wakeChunk(response, resolveListeners, handler.value); + wakeChunk(response, resolveListeners, handler.value, initializedChunk); } } } @@ -835,10 +953,11 @@ function rejectReference( } function waitForReference( - referencedChunk: PendingChunk | BlockedChunk, + response: Response, + referencedChunk: BlockedChunk, parentObject: Object, key: string, - response: Response, + arrayRoot: null | NestedArrayContext, map: (response: Response, model: any, parentObject: Object, key: string) => T, path: Array, ): T { @@ -862,6 +981,7 @@ function waitForReference( key, map, path, + arrayRoot, }; // Add "listener". @@ -885,6 +1005,7 @@ function getOutlinedModel( reference: string, parentObject: Object, key: string, + referenceArrayRoot: null | NestedArrayContext, map: (response: Response, model: any, parentObject: Object, key: string) => T, ): T { const path = reference.split(':'); @@ -899,6 +1020,9 @@ function getOutlinedModel( switch (chunk.status) { case INITIALIZED: let value = chunk.value; + let arrayRoot: null | NestedArrayContext = chunk.reason; + let localLength: number = 0; + const rootArrayContexts = response._rootArrayContexts; for (let i = 1; i < path.length; i++) { const name = path[i]; if ( @@ -909,16 +1033,64 @@ function getOutlinedModel( hasOwnProperty.call(value, name) ) { value = value[name]; + if (isArray(value)) { + localLength = 0; + arrayRoot = rootArrayContexts.get(value) || arrayRoot; + } else { + arrayRoot = null; + if (typeof value === 'string') { + localLength = value.length; + } else if (typeof value === 'bigint') { + // Estimate the length to avoid expensive toString() calls on large + // BigInt values. If the value is too large, we get Infinity, which + // will trigger the array size limit error. + // eslint-disable-next-line react-internal/no-primitive-constructors + const n = Math.abs(Number(value)); + if (n === 0) { + localLength = 1; + } else { + localLength = Math.floor(Math.log10(n)) + 1; + } + } else if (ArrayBuffer.isView(value)) { + localLength = value.byteLength; + } else { + localLength = 0; + } + } } else { throw new Error('Invalid reference.'); } } const chunkValue = map(response, value, parentObject, key); + + // Add any array counts to the reference's array root. The value that we're + // resolving might have deep nesting that we need to resolve. + if (referenceArrayRoot !== null) { + if (arrayRoot !== null) { + if (arrayRoot.fork) { + referenceArrayRoot.fork = true; + } + bumpArrayCount(referenceArrayRoot, arrayRoot.count, response); + } else if (localLength > 0) { + bumpArrayCount(referenceArrayRoot, localLength, response); + } + } // There's no Element nor Debug Info in the ReplyServer so we don't have to check those here. return chunkValue; - case PENDING: case BLOCKED: - return waitForReference(chunk, parentObject, key, response, map, path); + return waitForReference( + response, + chunk, + parentObject, + key, + referenceArrayRoot, + map, + path, + ); + case PENDING: + // If we don't have the referenced chunk yet, then this must be a forward reference, + // which is not allowed. + throw new Error('Invalid forward reference.'); default: // This is an error. Instead of erroring directly, we're going to encode this on // an initialization handler. @@ -944,16 +1116,40 @@ function createMap( response: Response, model: Array<[any, any]>, ): Map { - return new Map(model); + if (!isArray(model)) { + throw new Error('Invalid Map initializer.'); + } + if ((model as any).$$consumed === true) { + throw new Error('Already initialized Map.'); + } + const map = new Map(model); + (model as any).$$consumed = true; + return map; } function createSet(response: Response, model: Array): Set { - return new Set(model); + if (!isArray(model)) { + throw new Error('Invalid Set initializer.'); + } + if ((model as any).$$consumed === true) { + throw new Error('Already initialized Set.'); + } + const set = new Set(model); + (model as any).$$consumed = true; + return set; } function extractIterator(response: Response, model: Array): Iterator { + if (!isArray(model)) { + throw new Error('Invalid Iterator initializer.'); + } + if ((model as any).$$consumed === true) { + throw new Error('Already initialized Iterator.'); + } // $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array. - return model[Symbol.iterator](); + const iterator = model[Symbol.iterator](); + (model as any).$$consumed = true; + return iterator; } function createModel( @@ -977,6 +1173,7 @@ function parseTypedArray( bytesPerElement: number, parentObject: Object, parentKey: string, + referenceArrayRoot: null | NestedArrayContext, ): null { const id = parseInt(reference.slice(2), 16); const prefix = response._prefix; @@ -985,10 +1182,15 @@ function parseTypedArray( if (chunks.has(id)) { throw new Error('Already initialized typed array.'); } + chunks.set( + id, + // We don't need to put the actual Blob in the chunk, + // because it shouldn't be accessed by anything else. + createErroredChunk(response, new Error('Already initialized typed array.')), + ); // We should have this backingEntry in the store already because we emitted // it before referencing it. It should be a Blob. - // TODO: Use getOutlinedModel to allow us to emit the Blob later. We should be able to do that now. const backingEntry: Blob = (response._formData.get(key): any); const promise: Promise = backingEntry.arrayBuffer(); @@ -1011,17 +1213,28 @@ function parseTypedArray( } function fulfill(buffer: ArrayBuffer): void { - const resolvedValue: T = - constructor === ArrayBuffer - ? (buffer: any) - : (new constructor(buffer): any); + try { + if (referenceArrayRoot !== null) { + bumpArrayCount(referenceArrayRoot, buffer.byteLength, response); + } - parentObject[parentKey] = resolvedValue; + const resolvedValue: T = + constructor === ArrayBuffer + ? (buffer: any) + : (new constructor(buffer): any); - // If this is the root object for a model reference, where `handler.value` - // is a stale `null`, the resolved value can be used directly. - if (parentKey === '' && handler.value === null) { - handler.value = resolvedValue; + if (key !== __PROTO__) { + parentObject[parentKey] = resolvedValue; + } + + // If this is the root object for a model reference, where `handler.value` + // is a stale `null`, the resolved value can be used directly. + if (parentKey === '' && handler.value === null) { + handler.value = resolvedValue; + } + } catch (x) { + reject(x); + return; } handler.deps--; @@ -1035,9 +1248,11 @@ function parseTypedArray( const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = handler.value; + // We don't keep an array count for this since it won't be referenced again. + // In fact, we don't really need to store this chunk at all. initializedChunk.reason = null; if (resolveListeners !== null) { - wakeChunk(response, resolveListeners, handler.value); + wakeChunk(response, resolveListeners, handler.value, initializedChunk); } } } @@ -1111,6 +1326,13 @@ function parseReadableStream( }, }); let previousBlockedChunk: SomeChunk | null = null; + function enqueue(value: T): void { + if (type === 'bytes' && !ArrayBuffer.isView(value)) { + flightController.error(new Error('Invalid data for bytes stream.')); + return; + } + controller.enqueue(value); + } const flightController = { enqueueModel(json: string): void { if (previousBlockedChunk === null) { @@ -1124,22 +1346,16 @@ function parseReadableStream( initializeModelChunk(chunk); const initializedChunk: SomeChunk = chunk; if (initializedChunk.status === INITIALIZED) { - controller.enqueue(initializedChunk.value); + enqueue(initializedChunk.value); } else { - chunk.then( - v => controller.enqueue(v), - e => controller.error((e: any)), - ); + chunk.then(enqueue, flightController.error); previousBlockedChunk = chunk; } } else { // We're still waiting on a previous chunk so we can't enqueue quite yet. const blockedChunk = previousBlockedChunk; const chunk: SomeChunk = createPendingChunk(response); - chunk.then( - v => controller.enqueue(v), - e => controller.error((e: any)), - ); + chunk.then(enqueue, flightController.error); previousBlockedChunk = chunk; blockedChunk.then(function () { if (previousBlockedChunk === chunk) { @@ -1185,24 +1401,23 @@ function parseReadableStream( return stream; } -function asyncIterator(this: $AsyncIterator) { +function FlightIterator( + this: {next: (arg: void) => SomeChunk>, ...}, + next: (arg: void) => SomeChunk>, +) { + this.next = next; + // TODO: Add return/throw as options for aborting. +} +// TODO: The iterator could inherit the AsyncIterator prototype which is not exposed as +// a global but exists as a prototype of an AsyncGenerator. However, it's not needed +// to satisfy the iterable protocol. +FlightIterator.prototype = ({}: any); +FlightIterator.prototype[ASYNC_ITERATOR] = function asyncIterator( + this: $AsyncIterator, +) { // Self referencing iterator. return this; -} - -function createIterator( - next: (arg: void) => SomeChunk>, -): $AsyncIterator { - const iterator: any = { - next: next, - // TODO: Add return/throw as options for aborting. - }; - // TODO: The iterator could inherit the AsyncIterator prototype which is not exposed as - // a global but exists as a prototype of an AsyncGenerator. However, it's not needed - // to satisfy the iterable protocol. - (iterator: any)[ASYNC_ITERATOR] = asyncIterator; - return iterator; -} +}; function parseAsyncIterable( response: Response, @@ -1285,7 +1500,8 @@ function parseAsyncIterable( const iterable: $AsyncIterable = { [ASYNC_ITERATOR](): $AsyncIterator { let nextReadIndex = 0; - return createIterator(arg => { + // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors + return new FlightIterator((arg: void) => { if (arg !== undefined) { throw new Error( 'Values cannot be passed to next() of AsyncIterables passed to Client Components.', @@ -1320,11 +1536,15 @@ function parseModelString( key: string, value: string, reference: void | string, + arrayRoot: null | NestedArrayContext, ): any { if (value[0] === '$') { switch (value[1]) { case '$': { // This was an escaped string value. + if (arrayRoot !== null) { + bumpArrayCount(arrayRoot, value.length - 1, response); + } return value.slice(1); } case '@': { @@ -1336,7 +1556,14 @@ function parseModelString( case 'h': { // Server Reference const ref = value.slice(2); - return getOutlinedModel(response, ref, obj, key, loadServerReference); + return getOutlinedModel( + response, + ref, + obj, + key, + null, + loadServerReference, + ); } case 'T': { // Temporary Reference @@ -1358,12 +1585,12 @@ function parseModelString( case 'Q': { // Map const ref = value.slice(2); - return getOutlinedModel(response, ref, obj, key, createMap); + return getOutlinedModel(response, ref, obj, key, null, createMap); } case 'W': { // Set const ref = value.slice(2); - return getOutlinedModel(response, ref, obj, key, createSet); + return getOutlinedModel(response, ref, obj, key, null, createSet); } case 'K': { // FormData @@ -1374,19 +1601,30 @@ function parseModelString( // We assume that the reference to FormData always comes after each // entry that it references so we can assume they all exist in the // backing store already. - // $FlowFixMe[prop-missing] FormData has forEach on it. - backingFormData.forEach((entry: File | string, entryKey: string) => { + // Clone the keys to workaround bugs in the delete-while-iterating + // algorithm of FormData. + const keys = Array.from(backingFormData.keys()); + for (let i = 0; i < keys.length; i++) { + const entryKey = keys[i]; if (entryKey.startsWith(formPrefix)) { - // $FlowFixMe[incompatible-call] - data.append(entryKey.slice(formPrefix.length), entry); + const entries = backingFormData.getAll(entryKey); + const newKey = entryKey.slice(formPrefix.length); + for (let j = 0; j < entries.length; j++) { + // $FlowFixMe[incompatible-call] + data.append(newKey, entries[j]); + } + // These entries have now all been consumed. Let's free it. + // This also ensures that we don't have any entries left if we + // see the same key twice. + backingFormData.delete(entryKey); } - }); + } return data; } case 'i': { // Iterator const ref = value.slice(2); - return getOutlinedModel(response, ref, obj, key, extractIterator); + return getOutlinedModel(response, ref, obj, key, null, extractIterator); } case 'I': { // $Infinity @@ -1415,36 +1653,151 @@ function parseModelString( } case 'n': { // BigInt - return BigInt(value.slice(2)); + const bigIntStr = value.slice(2); + if (bigIntStr.length > MAX_BIGINT_DIGITS) { + throw new Error( + 'BigInt is too large. Received ' + + bigIntStr.length + + ' digits but the limit is ' + + MAX_BIGINT_DIGITS + + '.', + ); + } + if (arrayRoot !== null) { + bumpArrayCount(arrayRoot, bigIntStr.length, response); + } + return BigInt(bigIntStr); } - } - switch (value[1]) { case 'A': - return parseTypedArray(response, value, ArrayBuffer, 1, obj, key); + return parseTypedArray( + response, + value, + ArrayBuffer, + 1, + obj, + key, + arrayRoot, + ); case 'O': - return parseTypedArray(response, value, Int8Array, 1, obj, key); + return parseTypedArray( + response, + value, + Int8Array, + 1, + obj, + key, + arrayRoot, + ); case 'o': - return parseTypedArray(response, value, Uint8Array, 1, obj, key); + return parseTypedArray( + response, + value, + Uint8Array, + 1, + obj, + key, + arrayRoot, + ); case 'U': - return parseTypedArray(response, value, Uint8ClampedArray, 1, obj, key); + return parseTypedArray( + response, + value, + Uint8ClampedArray, + 1, + obj, + key, + arrayRoot, + ); case 'S': - return parseTypedArray(response, value, Int16Array, 2, obj, key); + return parseTypedArray( + response, + value, + Int16Array, + 2, + obj, + key, + arrayRoot, + ); case 's': - return parseTypedArray(response, value, Uint16Array, 2, obj, key); + return parseTypedArray( + response, + value, + Uint16Array, + 2, + obj, + key, + arrayRoot, + ); case 'L': - return parseTypedArray(response, value, Int32Array, 4, obj, key); + return parseTypedArray( + response, + value, + Int32Array, + 4, + obj, + key, + arrayRoot, + ); case 'l': - return parseTypedArray(response, value, Uint32Array, 4, obj, key); + return parseTypedArray( + response, + value, + Uint32Array, + 4, + obj, + key, + arrayRoot, + ); case 'G': - return parseTypedArray(response, value, Float32Array, 4, obj, key); + return parseTypedArray( + response, + value, + Float32Array, + 4, + obj, + key, + arrayRoot, + ); case 'g': - return parseTypedArray(response, value, Float64Array, 8, obj, key); + return parseTypedArray( + response, + value, + Float64Array, + 8, + obj, + key, + arrayRoot, + ); case 'M': - return parseTypedArray(response, value, BigInt64Array, 8, obj, key); + return parseTypedArray( + response, + value, + BigInt64Array, + 8, + obj, + key, + arrayRoot, + ); case 'm': - return parseTypedArray(response, value, BigUint64Array, 8, obj, key); + return parseTypedArray( + response, + value, + BigUint64Array, + 8, + obj, + key, + arrayRoot, + ); case 'V': - return parseTypedArray(response, value, DataView, 1, obj, key); + return parseTypedArray( + response, + value, + DataView, + 1, + obj, + key, + arrayRoot, + ); case 'B': { // Blob const id = parseInt(value.slice(2), 16); @@ -1455,8 +1808,6 @@ function parseModelString( const backingEntry: Blob = (response._formData.get(blobKey): any); return backingEntry; } - } - switch (value[1]) { case 'R': { return parseReadableStream(response, value, undefined, obj, key); } @@ -1472,16 +1823,30 @@ function parseModelString( } // We assume that anything else is a reference ID. const ref = value.slice(1); - return getOutlinedModel(response, ref, obj, key, createModel); + return getOutlinedModel(response, ref, obj, key, arrayRoot, createModel); + } + if (arrayRoot !== null) { + bumpArrayCount(arrayRoot, value.length, response); } return value; } +const DEFAULT_MAX_ARRAY_NESTING = 1000000; + +// Limit BigInt size to prevent CPU exhaustion from parsing very large values. +// 300 digits covers most practical use cases (even 512-bit integers need only +// ~154 digits) and aligns with the implicit limit from the Number approximation +// checks in fulfillReference and getOutlinedModel. +const MAX_BIGINT_DIGITS = 300; + +export const MAX_BOUND_ARGS = 1000; + export function createResponse( bundlerConfig: ServerManifest, formFieldPrefix: string, temporaryReferences: void | TemporaryReferenceSet, backingFormData?: FormData = new FormData(), + arraySizeLimit?: number = DEFAULT_MAX_ARRAY_NESTING, ): Response { const chunks: Map> = new Map(); const response: Response = { @@ -1492,6 +1857,8 @@ export function createResponse( _closed: false, _closedReason: null, _temporaryReferences: temporaryReferences, + _rootArrayContexts: new WeakMap(), + _arraySizeLimit: arraySizeLimit, }; return response; } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 1f9bb0a77fa..f31fa45f7a7 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -557,6 +557,8 @@ type DeferredDebugStore = { existing: Map, }; +const __PROTO__ = '__proto__'; + const OPENING = 10; const OPEN = 11; const ABORTING = 12; @@ -3447,6 +3449,17 @@ function renderModelDestructive( // Set the currently rendering model task.model = value; + if (__DEV__) { + if (parentPropertyName === __PROTO__) { + callWithDebugContextInDEV(request, task, () => { + console.error( + 'Expected not to serialize an object with own property `__proto__`. When parsed this property will be omitted.%s', + describeObjectForErrorMessage(parent, parentPropertyName), + ); + }); + } + } + // Special Symbol, that's very common. if (value === REACT_ELEMENT_TYPE) { return '$'; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index d8c8e0b7685..46bb2e6bccf 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -555,5 +555,16 @@ "567": "Already initialized stream.", "568": "Already initialized typed array.", "569": "Cannot have cyclic thenables.", - "570": "Invalid reference." + "570": "Invalid reference.", + "571": "Maximum array nesting exceeded. Large nested arrays can be dangerous. Try adding intermediate objects.", + "572": "Already initialized Map.", + "573": "Already initialized Set.", + "574": "Invalid forward reference.", + "575": "Invalid Map initializer.", + "576": "Invalid Set initializer.", + "577": "Invalid Iterator initializer.", + "578": "Already initialized Iterator.", + "579": "Invalid data for bytes stream.", + "580": "Server Function has too many bound arguments. Received %s but the limit is %s.", + "581": "BigInt is too large. Received %s digits but the limit is %s." } From 8c34556ca8df0dab34bbaf68e0dd15cd4a5e3f7f Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 26 Jan 2026 21:13:16 +0100 Subject: [PATCH 3/3] [Flight] Fix react-markup types for server references (#35634) --- .../react-client/src/forks/ReactFlightClientConfig.markup.js | 4 ++-- packages/react-server/src/ReactFlightActionServer.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js index 76a973b377b..52f96ef9cbd 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js @@ -18,7 +18,7 @@ export * from 'react-client/src/ReactClientConsoleConfigPlain'; export type ModuleLoading = null; export type ServerConsumerModuleMap = null; export opaque type ServerManifest = null; -export opaque type ServerReferenceId = string; +export type ServerReferenceId = string; export opaque type ClientReferenceMetadata = null; export opaque type ClientReference = null; // eslint-disable-line no-unused-vars @@ -43,7 +43,7 @@ export function resolveClientReference( export function resolveServerReference( config: ServerManifest, - id: mixed, + id: ServerReferenceId, ): ClientReference { throw new Error( 'renderToHTML should not have emitted Server References. This is a bug in React.', diff --git a/packages/react-server/src/ReactFlightActionServer.js b/packages/react-server/src/ReactFlightActionServer.js index ddcf36e96f9..a3a47a9f979 100644 --- a/packages/react-server/src/ReactFlightActionServer.js +++ b/packages/react-server/src/ReactFlightActionServer.js @@ -46,7 +46,7 @@ function bindArgs(fn: any, args: any) { function loadServerReference( bundlerConfig: ServerManifest, metaData: { - id: string, + id: ServerReferenceId, bound: null | Promise>, }, ): Promise {