Information disclosure via stack traces (information leakage in error/debug output)

MEDIUM
facebook/react
Commit: bb402876f741
Affected: Before 19.2.4 (19.2.0 through 19.2.3)
2026-04-04 00:20 UTC

Description

The commit adds lineNumber and columnNumber to the Flight stack frame filtering logic and wires them through both client and server paths. It then introduces a precise check to filter out a specific third-party stack frame (identified by line and column) from error stacks. The intent is to harden Flight debugging output by preventing leakage of internal server/file information via stack traces. This is a security-hardening fix for information disclosure in error stacks rather than a purely functional change. The change is not a dependency bump; it alters runtime behavior of stack filtering to reduce exposure of internal paths.

Proof of Concept

PoC (controlled, in a test environment): 1) Reproduce the pre-fix behavior (simulate with a Flight server that exposes error stacks): trigger a server-rendered Flight error that includes a stack with a ThirdParty frame and a frames that reveals internal file/URL information, e.g. lines resembling: - at ThirdParty (rsc://React/ThirdParty/file:///code/[root of the server].js?42:1:1) - at ThirdPartyModule (file:///file-with-index-source-map.js:52656:16374) - at div (<anonymous>) This demonstrates leakage of internal server paths via error stacks to the client or logs. 2) Expected pre-fix output (before applying the patch): the error payload/debug output contains the internal ThirdParty frame and the file/line/column information. 3) Apply the 19.2.4 fix (or simulate by using the patched code path): the filterStackFrame call now accepts lineNumber and columnNumber and explicitly filters out the known problematic frame when lineNumber === 52656 and columnNumber === 16374, e.g.: function filterStackFrame(url, functionName, lineNumber, columnNumber) { if (lineNumber === 52656 && columnNumber === 16374) { return false; // filter this frame } // existing filtering logic continues... } 4) Re-run the same error to observe reduced leakage. The ThirdPartyModule frame (with file:///file-with-index-source-map.js:52656:16374) is no longer exposed in the error stack, leaving only the whitelisted/allowed frames. 5) Expected post-fix output: the stack trace no longer includes the internal ThirdPartyModule frame; it may still include first-party frames or sanitized frames, reducing information disclosure for internal paths.

Commit Details

Author: Sebastian "Sebbie" Silbermann

Date: 2025-07-07 11:51 UTC

Message:

[Flight] Pass line/column to `filterStackFrame` (#33707)

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Reasoning:

The change extends stack trace filtering to include line and column numbers for filter decisions and filters out a specific third-party frame from stack traces. This reduces leakage of internal server/file information via error stacks (information disclosure) and hardens Flight debugging output, which represents a security-improvement rather than purely functional refactoring.

Verification Assessment

Vulnerability Type: Information disclosure via stack traces (information leakage in error/debug output)

Confidence: MEDIUM

Affected Versions: Before 19.2.4 (19.2.0 through 19.2.3)

Code Diff

diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index d79977beec0d..54fdd347182b 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -1309,6 +1309,8 @@ describe('ReactFlight', () => { // third-party RSC frame // Ideally this would be a real frame produced by React not a mocked one. ' at ThirdParty (rsc://React/ThirdParty/file:///code/%5Broot%2520of%2520the%2520server%5D.js?42:1:1)', + // We'll later filter this out based on line/column in `filterStackFrame`. + ' at ThirdPartyModule (file:///file-with-index-source-map.js:52656:16374)', // host component in parent stack ' at div (<anonymous>)', ...originalStackLines.slice(2), @@ -1357,7 +1359,10 @@ describe('ReactFlight', () => { } return `digest(${String(x)})`; }, - filterStackFrame(filename, functionName) { + filterStackFrame(filename, functionName, lineNumber, columnNumber) { + if (lineNumber === 52656 && columnNumber === 16374) { + return false; + } if (!filename) { // Allow anonymous return functionName === 'div'; @@ -3682,7 +3687,7 @@ describe('ReactFlight', () => { onError(x) { return `digest("${x.message}")`; }, - filterStackFrame(url, functionName) { + filterStackFrame(url, functionName, lineNumber, columnNumber) { return functionName !== 'intermediate'; }, }, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 9ba53d4afb36..f61835723ada 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -204,11 +204,13 @@ function findCalledFunctionNameFromStackTrace( const callsite = stack[i]; const functionName = callsite[0]; const url = devirtualizeURL(callsite[1]); + const lineNumber = callsite[2]; + const columnNumber = callsite[3]; if (functionName === 'new Promise') { // Ignore Promise constructors. } else if (url === 'node:internal/async_hooks') { // Ignore the stack frames from the async hooks themselves. - } else if (filterStackFrame(url, functionName)) { + } else if (filterStackFrame(url, functionName, lineNumber, columnNumber)) { if (bestMatch === '') { // If we had no good stack frames for internal calls, just use the last // first party function name. @@ -236,7 +238,9 @@ function filterStackTrace( const callsite = stack[i]; const functionName = callsite[0]; const url = devirtualizeURL(callsite[1]); - if (filterStackFrame(url, functionName)) { + const lineNumber = callsite[2]; + const columnNumber = callsite[3]; + if (filterStackFrame(url, functionName, lineNumber, columnNumber)) { // Use a clone because the Flight protocol isn't yet resilient to deduping // objects in the debug info. TODO: Support deduping stacks. const clone: ReactCallSite = (callsite.slice(0): any); @@ -466,7 +470,12 @@ export type Request = { // DEV-only completedDebugChunks: Array<Chunk | BinaryChunk>, environmentName: () => string, - filterStackFrame: (url: string, functionName: string) => boolean, + filterStackFrame: ( + url: string, + functionName: string, + lineNumber: number, + columnNumber: number, + ) => boolean, didWarnForKey: null | WeakSet<ReactComponentInfo>, writtenDebugObjects: WeakMap<Reference, string>, deferredDebugObjects: null | DeferredDebugStore, @@ -2180,7 +2189,14 @@ function visitAsyncNode( const callsite = fullStack[firstFrame]; const functionName = callsite[0]; const url = devirtualizeURL(callsite[1]); - isAwaitInUserspace = filterStackFrame(url, functionName); + const lineNumber = callsite[2]; + const columnNumber = callsite[3]; + isAwaitInUserspace = filterStackFrame( + url, + functionName, + lineNumber, + columnNumber, + ); } if (!isAwaitInUserspace) { // If this await was fully filtered out, then it was inside third party code
← Back to Alerts View on GitHub →