Information Disclosure

HIGH
facebook/react
Commit: 52912a14531d
Affected: < 19.2.4
2026-06-25 20:44 UTC

Description

The commit Adds ignore-listed stack frame disclosure in React DevTools by introducing an ignore-list mechanism for stack traces and a UI toggle to show/hide ignored frames. It adds StackTraceGroup and related changes to only render internal frames when the user explicitly opts in, and to hide frames that are marked as ignored by the symbolication layer. This mitigates an information-disclosure risk where internal implementation details and file paths could be visible inDevTools stack traces. By default, suppressed internal frames are not shown, reducing leakage of internal paths and module structure.

Proof of Concept

Proof of concept (actionable reproduction): Prerequisites: - A React app using a DevTools-enabled workflow with a pre-fix DevTools behavior that disclosed internal stack frames. - Access to DevTools (or the error overlay) that renders a component stack trace. Steps to reproduce (pre-fix behavior): 1) Create a small component that intentionally throws during render: // src/App.jsx export default function App() { throw new Error('boom'); } 2) Render App in a React environment where DevTools stack traces reveal frames. 3) Open DevTools error overlay or console; observe a stack trace that includes internal frames such as: Error: boom at App (src/App.jsx:5) at renderWithHooks (node_modules/react/cjs/react.development.js:<num>:<col>) at render (node_modules/react-dom/cjs/react-dom.development.js:<num>:<col>) ... The exact internal frames (paths under node_modules or internal/react) reveal implementation details. Steps to reproduce (post-fix behavior - using the commit in 19.2.4+): 1) Use the same component that throws. 2) Observe DevTools stack trace in the error overlay or console. 3) Internal frames should be hidden or collapsed, and an option to reveal ignored frames (Ignore List) is available via the new toggle (Show ignore list). 4) If you enable Show ignore list, you may see only frames that are not ignored or frames explicitly marked for display while the rest remain hidden. Notes: - For a deterministic PoC, instrument the app to trigger an error during render or a failed suspense boundary, and compare the displayed stack trace between a pre-fix DevTools (full internal frames) and post-fix DevTools (internal frames hidden by default, with an explicit toggle to reveal ignored frames). - The PoC demonstrates the attack surface: an unauthorised observer could deduce internal paths or module structure from stack traces if internal frames are disclosed. The fix reduces this disclosure by default.

Commit Details

Author: Jiwon Choi

Date: 2026-06-25 13:57 UTC

Message:

[DevTools] Add ignore-listed stack frame disclosure (#36828)

Triage Assessment

Vulnerability Type: Information Disclosure

Confidence: HIGH

Reasoning:

The commit adds ignore-listed stack frame disclosure in DevTools, introducing an ignore toggle for stack traces and rendering logic to hide certain frames. This directly reduces inadvertent information disclosure of internal frames, a security-impacting improvement (information disclosure).

Verification Assessment

Vulnerability Type: Information Disclosure

Confidence: HIGH

Affected Versions: < 19.2.4

Code Diff

diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css index 6c56aec68998..be1ec54f1ed1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css @@ -143,3 +143,8 @@ padding-left: 1.25rem; margin-top: 0.25rem; } + +.SuspendedBySkeleton { + padding: 0.25rem; + padding-left: 1.25rem; +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index ffcdd7df0ca8..21ea1d5980c1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -9,7 +9,7 @@ import {copy} from 'clipboard-js'; import * as React from 'react'; -import {useState, useTransition} from 'react'; +import {use, useContext, useState, useTransition} from 'react'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; import KeyValue from './KeyValue'; @@ -17,10 +17,13 @@ import {serializeDataForCopy, pluralize} from '../utils'; import Store from '../../store'; import styles from './InspectedElementSharedStyles.css'; import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck'; -import StackTraceView from './StackTraceView'; +import FetchFileWithCachingContext from './FetchFileWithCachingContext'; +import StackTraceView, {IgnoreListToggleButton} from './StackTraceView'; import OwnerView from './OwnerView'; import {meta} from '../../../hydration'; +import Skeleton from './Skeleton'; import useInferredName from '../useInferredName'; +import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource'; import {getClassNameForEnvironment} from '../SuspenseTab/SuspenseEnvironmentColors.js'; @@ -29,6 +32,8 @@ import type { SerializedAsyncInfo, } from 'react-devtools-shared/src/frontend/types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import type {ReactStackTrace} from 'shared/ReactTypes'; +import type {SourceMappedLocation} from 'react-devtools-shared/src/symbolicateSource'; import { UNKNOWN_SUSPENDERS_NONE, @@ -94,6 +99,78 @@ function formatBytes(bytes: number) { return (bytes / 1_000_000_000).toFixed(1) + ' gB'; } +type StackTraceGroupProps = { + children: (showIgnoreList: boolean) => React.Node, + ioStack: null | ReactStackTrace, + asyncInfoStack: null | ReactStackTrace, +}; + +function StackTraceGroup({ + children, + ioStack, + asyncInfoStack, +}: StackTraceGroupProps): React.Node { + const [showIgnoreList, setShowIgnoreList] = useState(false); + const fetchFileWithCaching = useContext(FetchFileWithCachingContext); + + const ioStackHasIgnoredFrames = + ioStack !== null && + ioStack.some(callSite => { + const [, virtualURL, virtualLine, virtualColumn] = callSite; + + // symbolicated output is cached + const symbolicatedCallSite: null | SourceMappedLocation = + fetchFileWithCaching !== null + ? use( + symbolicateSourceWithCache( + fetchFileWithCaching, + virtualURL, + virtualLine, + virtualColumn, + ), + ) + : null; + + return symbolicatedCallSite !== null && symbolicatedCallSite.ignored; + }); + + const asyncInfoStackHasIgnoredFrames = + asyncInfoStack !== null && + asyncInfoStack.some(callSite => { + const [, virtualURL, virtualLine, virtualColumn] = callSite; + + // symbolicated output is cached + const symbolicatedCallSite: null | SourceMappedLocation = + fetchFileWithCaching !== null + ? use( + symbolicateSourceWithCache( + fetchFileWithCaching, + virtualURL, + virtualLine, + virtualColumn, + ), + ) + : null; + + return symbolicatedCallSite !== null && symbolicatedCallSite.ignored; + }); + + const hasIgnoredFrames = + ioStackHasIgnoredFrames || asyncInfoStackHasIgnoredFrames; + + return ( + <> + {children(showIgnoreList)} + {hasIgnoredFrames && ( + <IgnoreListToggleButton + onClick={() => setShowIgnoreList(prev => !prev)} + showIgnoreList={showIgnoreList} + /> + )} + </> + ); +} + function SuspendedByRow({ bridge, element, @@ -203,102 +280,126 @@ function SuspendedByRow({ </Button> {isOpen && ( <div className={styles.CollapsableContent}> - {showIOStack && ( - <StackTraceView - stack={ioInfo.stack} - environmentName={ - ioOwner !== null && ioOwner.env === ioInfo.env - ? null - : ioInfo.env - } - /> - )} - {ioOwner !== null && - ioOwner.id !== inspectedElement.id && - (showIOStack || - !showAwaitStack || - asyncOwner === null || - ioOwner.id !== asyncOwner.id) ? ( - <OwnerView - key={ioOwner.id} - displayName={ioOwner.displayName || 'Anonymous'} - environmentName={ - ioOwner.env === inspectedElement.env && - ioOwner.env === ioInfo.env - ? null - : ioOwner.env - } - hocDisplayNames={ioOwner.hocDisplayNames} - compiledWithForget={ioOwner.compiledWithForget} - id={ioOwner.id} - isInStore={store.containsElement(ioOwner.id)} - type={ioOwner.type} - /> - ) : null} - {showAwaitStack ? ( - <> - <div className={styles.SmallHeader}>awaited at:</div> - {asyncInfo.stack !== null && asyncInfo.stack.length > 0 && ( - <StackTraceView - stack={asyncInfo.stack} - environmentName={ - asyncOwner !== null && asyncOwner.env === asyncInfo.env - ? null - : asyncInfo.env - } - /> + <React.Suspense + fallback={ + <div className={styles.SuspendedBySkeleton}> + <Skeleton height={16} width="40%" /> + </div> + }> + <StackTraceGroup + ioStack={showIOStack ? ioInfo.stack : null} + asyncInfoStack={showAwaitStack ? asyncInfo.stack : null}> + {(showIgnoreList: boolean) => ( + <> + {showIOStack && ( + <StackTraceView + stack={ioInfo.stack} + environmentName={ + ioOwner !== null && ioOwner.env === ioInfo.env + ? null + : ioInfo.env + } + showIgnoreList={showIgnoreList} + /> + )} + {ioOwner !== null && + ioOwner.id !== inspectedElement.id && + (showIOStack || + !showAwaitStack || + asyncOwner === null || + ioOwner.id !== asyncOwner.id) ? ( + <OwnerView + key={ioOwner.id} + displayName={ioOwner.displayName || 'Anonymous'} + environmentName={ + ioOwner.env === inspectedElement.env && + ioOwner.env === ioInfo.env + ? null + : ioOwner.env + } + hocDisplayNames={ioOwner.hocDisplayNames} + compiledWithForget={ioOwner.compiledWithForget} + id={ioOwner.id} + isInStore={store.containsElement(ioOwner.id)} + type={ioOwner.type} + /> + ) : null} + {showAwaitStack ? ( + <> + <div className={styles.SmallHeader}>awaited at:</div> + {asyncInfo.stack !== null && + asyncInfo.stack.length > 0 && ( + <StackTraceView + stack={asyncInfo.stack} + environmentName={ + asyncOwner !== null && + asyncOwner.env === asyncInfo.env + ? null + : asyncInfo.env + } + showIgnoreList={showIgnoreList} + /> + )} + {asyncOwner !== null && + asyncOwner.id !== inspectedElement.id ? ( + <OwnerView + key={asyncOwner.id} + displayName={asyncOwner.displayName || 'Anonymous'} + environmentName={ + asyncOwner.env === inspectedElement.env && + asyncOwner.env === asyncInfo.env + ? null + : asyncOwner.env + } + hocDisplayNames={asyncOwner.hocDisplayNames} + compiledWithForget={asyncOwner.compiledWithForget} + id={asyncOwner.id} + isInStore={store.containsElement(asyncOwner.id)} + type={asyncOwner.type} + /> + ) : null} + </> + ) : null} + <div className={styles.PreviewContainer}> + <KeyValue + alphaSort={true} + bridge={bridge} + canDeletePaths={false} + canEditValues={false} + canRenamePaths={false} + depth={1} + element={element} + hidden={false} + inspectedElement={inspectedElement} + name={ + isFulfilled + ? 'awaited value' + : isRejected + ? 'rejected with' + : 'pending value' + } + path={ + isFulfilled + ? [index, 'awaited', 'value', 'value'] + : isRejected + ? [index, 'awaited', 'value', 'reason'] + : [index, 'awaited', 'value'] + } + pathRoot="suspendedBy" + store={store} + value={ + isFulfilled + ? value.value + : isRejected + ? value.reason + : value + } + /> + </div> + </> )} - {asyncOwner !== null && asyncOwner.id !== inspectedElement.id ? ( - <OwnerView - key={asyncOwner.id} - displayName={asyncOwner.displayName || 'Anonymous'} - environmentName={ - asyncOwner.env === inspectedElement.env && - asyncOwner.env === asyncInfo.env - ? null - : asyncOwner.env - } - hocDisplayNames={asyncOwner.hocDisplayNames} - compiledWithForget={asyncOwner.compiledWithForget} - id={asyncOwner.id} - isInStore={store.containsElement(asyncOwner.id)} - type={asyncOwner.type} - /> - ) : null} - </> - ) : null} - <div className={styles.PreviewContainer}> - <KeyValue - alphaSort={true} - bridge={bridge} - canDeletePaths={false} - canEditValues={false} - canRenamePaths={false} - depth={1} - element={element} - hidden={false} - inspectedElement={inspectedElement} - name={ - isFulfilled - ? 'awaited value' - : isRejected - ? 'rejected with' - : 'pending value' - } - path={ - isFulfilled - ? [index, 'awaited', 'value', 'value'] - : isRejected - ? [index, 'awaited', 'value', 'reason'] - : [index, 'awaited', 'value'] - } - pathRoot="suspendedBy" - store={store} - value={ - isFulfilled ? value.value : isRejected ? value.reason : value - } - /> - </div> + </StackTraceGroup> + </React.Suspense> </div> )} </div> diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js index 6d3d0d604fd4..1346521441e5 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js @@ -8,7 +8,7 @@ */ import * as React from 'react'; -import {Fragment, useContext} from 'react'; +import {Fragment, useContext, useState, use} from 'react'; import {BridgeContext, StoreContext} from '../context'; import InspectedElementBadges from './InspectedElementBadges'; import InspectedElementContextTree from './InspectedElementContextTree'; @@ -19,15 +19,17 @@ import InspectedElementStateTree from './InspectedElementStateTree'; import InspectedElementStyleXPlugin from './InspectedElementStyleXPlugin'; import InspectedElementSuspendedBy from './InspectedElementSuspendedBy'; import NativeStyleEditor from './NativeStyleEditor'; +import FetchFileWithCachingContext from './FetchFileWithCachingContext'; import {enableStyleXFeatures} from 'react-devtools-feature-flags'; import InspectedElementSourcePanel from './InspectedElementSourcePanel'; -import StackTraceView from './StackTraceVie ... [truncated]
← Back to Alerts View on GitHub →