Information disclosure via stack traces (information leakage in error/debug output)
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