Information disclosure via stack traces

MEDIUM
vercel/next.js
Commit: cbc4888b188f
Affected: <=16.2.1
2026-04-04 07:41 UTC

Description

The commit adds support for an ignoreList in 3rd-party sourcemaps to filter out internal/third-party frames from Redbox/server error stacks. It introduces an is_ignored flag on StackFrame and on tokens, propagates this information through trace source operations and source-map handling, and updates server middleware and Turbopack core logic to respect ignored frames when constructing error traces. This reduces information disclosure via stack traces by avoiding leakage of internal or third-party implementation details. The changes affect server-side error tracing and Turbopack source-map handling, and are accompanied by test adjustments reflecting the new ignoreList handling behavior.

Proof of Concept

PoC (conceptual, demonstrates the vulnerability before vs after the fix): - Scenario: In a Next.js dev environment prior to the fix, an error thrown from a 3rd-party module with a sourcemap that does not correctly filter ignored frames could display internal file paths in Redbox, leaking implementation details. - Before fix (illustrative): a simulated Redbox-like overlay would render a stack trace that includes an internal frame from the ignored 3rd-party package, e.g. internal-pkg/ignored.ts (6:10). Code sketch to simulate the vulnerability (in a standalone Node/JS context): ```js function renderRedbox(frames) { // frames: Array<{ file: string, line: number, col: number, isIgnored?: boolean }> // If the ignoreList isn't respected, internal frames will appear. const visible = frames.filter(f => !f.isIgnored); return visible.map(f => `${f.file} (${f.line}:${f.col})`).join("\n"); } const frames = [ { file: 'app/pages/index.js', line: 18, col: 12, isIgnored: false }, { file: 'internal-pkg/ignored.ts', line: 6, col: 10, isIgnored: true }, // would leak if not filtered { file: 'internal-pkg/secret.ts', line: 2, col: 1, isIgnored: false } ]; console.log(renderRedbox(frames)); ``` - After fix (16.2.2+): the ignoreList logic marks the internal frame as ignored (isIgnored = true) and the overlay filters it out, resulting in a sanitized stack that omits internal/third-party frames: Expected output after fix: "app/pages/index.js (18:12)\ninternal-pkg/secret.ts (2:1)" - Prerequisites/conditions: A sourcemap for the 3rd-party module that includes an ignoreList and maps source files to a real project structure. The ignoreList must be consumed by the server-side trace construction (and Turbopack’s mapping) so that frames flagged as ignored are not shown in Redbox stacks.

Commit Details

Author: Sebastian "Sebbie" Silbermann

Date: 2026-02-23 11:53 UTC

Message:

[turbopack] Consider `ignoreList` of 3rd party sourcemaps in Redbox (#90317)

Triage Assessment

Vulnerability Type: Information disclosure

Confidence: MEDIUM

Reasoning:

The changes introduce handling for ignoreList in sourcemaps to skip/ignore certain frames in error stacks (Redbox). This reduces leakage of internal or third-party implementation details in error traces, mitigating information disclosure via stack traces. Code paths show consideration of ignored frames in both server-side tracing and Turbopack source map handling.

Verification Assessment

Vulnerability Type: Information disclosure via stack traces

Confidence: MEDIUM

Affected Versions: <=16.2.1

Code Diff

diff --git a/crates/next-napi-bindings/src/next_api/project.rs b/crates/next-napi-bindings/src/next_api/project.rs index d45cdfa790adbc..ffa796ff2e577f 100644 --- a/crates/next-napi-bindings/src/next_api/project.rs +++ b/crates/next-napi-bindings/src/next_api/project.rs @@ -2128,6 +2128,7 @@ pub fn project_compilation_events_subscribe( pub struct StackFrame { pub is_server: bool, pub is_internal: Option<bool>, + pub is_ignored: Option<bool>, pub original_file: Option<RcStr>, pub file: RcStr, /// 1-indexed, unlike source map tokens @@ -2235,7 +2236,7 @@ pub async fn project_trace_source_operation( frame.column.unwrap_or(1).saturating_sub(1), ); - let (original_file, line, column, method_name) = match token { + let (original_file, line, column, method_name, is_ignored) = match token { Token::Original(token) => ( match urlencoding::decode(&token.original_file)? { Cow::Borrowed(_) => token.original_file, @@ -2245,12 +2246,13 @@ pub async fn project_trace_source_operation( Some(token.original_line + 1), Some(token.original_column + 1), token.name, + token.is_ignored, ), Token::Synthetic(token) => { let Some(original_file) = token.guessed_original_file else { return Ok(Vc::cell(None)); }; - (original_file, None, None, None) + (original_file, None, None, None, false) } }; @@ -2303,6 +2305,7 @@ pub async fn project_trace_source_operation( column, is_server: frame.is_server, is_internal: Some(is_internal), + is_ignored: Some(is_ignored), }))) } diff --git a/packages/next/src/server/dev/middleware-turbopack.ts b/packages/next/src/server/dev/middleware-turbopack.ts index 96bdfa02fd2132..10e014e005bab9 100644 --- a/packages/next/src/server/dev/middleware-turbopack.ts +++ b/packages/next/src/server/dev/middleware-turbopack.ts @@ -87,6 +87,8 @@ async function batchedTraceSource( // Don't look up source for node_modules or internals. These can often be large bundled files. const ignored = + // Check the sourcemap's ignoreList (e.g. from 3rd party packages) + !!sourceFrame.isIgnored || shouldIgnorePath(originalFile ?? sourceFrame.file) || // isInternal means resource starts with turbopack:///[turbopack] !!sourceFrame.isInternal @@ -104,7 +106,6 @@ async function batchedTraceSource( source = await sourcePromise } - // TODO: get ignoredList from turbopack source map const ignorableFrame: IgnorableStackFrame = { file: sourceFrame.file, line1: sourceFrame.line ?? null, diff --git a/test/e2e/app-dir/server-source-maps/server-source-maps.test.ts b/test/e2e/app-dir/server-source-maps/server-source-maps.test.ts index 19a3d307c4ff23..0fe69d1db2c352 100644 --- a/test/e2e/app-dir/server-source-maps/server-source-maps.test.ts +++ b/test/e2e/app-dir/server-source-maps/server-source-maps.test.ts @@ -318,7 +318,6 @@ describe('app-dir - server source maps', () => { ) if (isTurbopack) { // TODO(veil): Turbopack errors because it thinks the sources are not part of the project. - // TODO(veil-NDX-910): Turbopack's sourcemap loader drops `ignoreList` in browser sourcemaps. await expect(browser).toDisplayCollapsedRedbox(` { "description": "ssr-error-log-ignore-listed", @@ -330,7 +329,6 @@ describe('app-dir - server source maps', () => { "stack": [ "logError app/ssr-error-log-ignore-listed/page.js (9:17)", "runWithInternalIgnored app/ssr-error-log-ignore-listed/page.js (19:13)", - "runInternalIgnored internal-pkg/ignored.ts (6:10)", "runWithExternalSourceMapped app/ssr-error-log-ignore-listed/page.js (18:29)", "runWithExternal app/ssr-error-log-ignore-listed/page.js (17:32)", "runWithInternalSourceMapped app/ssr-error-log-ignore-listed/page.js (16:18)", diff --git a/turbopack/crates/turbopack-core/src/source_map/mod.rs b/turbopack/crates/turbopack-core/src/source_map/mod.rs index 59181997ebee03..f1bf685ddc2f53 100644 --- a/turbopack/crates/turbopack-core/src/source_map/mod.rs +++ b/turbopack/crates/turbopack-core/src/source_map/mod.rs @@ -129,6 +129,8 @@ pub struct OriginalToken { pub original_line: u32, pub original_column: u32, pub name: Option<RcStr>, + /// Whether this token's source is in the sourcemap's `ignoreList`. + pub is_ignored: bool, } impl Token { @@ -159,6 +161,7 @@ impl Token { original_line: t.original_line, original_column: t.original_column, name: t.name.clone(), + is_ignored: t.is_ignored, }), Self::Synthetic(t) => Self::Synthetic(SyntheticToken { generated_line: t.generated_line + line_offset, @@ -187,6 +190,10 @@ impl From<swc_sourcemap::Token<'_>> for Token { original_line: t.get_src_line(), original_column: t.get_src_col(), name: t.get_name().cloned().map(RcStr::from), + // Set to false initially; will be updated by + // lookup_token_and_source_internal which has access to the full + // source map's ignoreList. + is_ignored: false, }) } else { Token::Synthetic(SyntheticToken { @@ -546,22 +553,32 @@ impl SourceMap { *guessed_original_file = Some(RcStr::from(source)); } - if need_source_content - && content.is_none() - && let Some(map) = map.as_regular_source_map() - { - content = tok.and_then(|tok| { - let src_id = tok.get_src_id(); - - let name = map.get_source(src_id); - let content = map.get_source_contents(src_id); - - let (name, content) = name.zip(content)?; - Some(sourcemap_content_source( - name.clone().into(), - content.clone().into(), - )) - }); + // Check ignoreList and resolve source content using the regular + // (possibly flattened) source map. For Index maps, + // SourceMapIndex::flatten() correctly transfers ignoreList entries + // from each section into the flattened map. + if let Some(flat_map) = map.as_regular_source_map() { + if let Token::Original(ref mut orig) = token + && let Some(source_name) = tok.and_then(|t| t.get_source().cloned()) + && let Some(idx) = flat_map.sources().position(|s| *s == *source_name) + { + orig.is_ignored = flat_map.ignore_list().any(|id| *id == idx as u32); + } + + if need_source_content && content.is_none() { + content = tok.and_then(|tok| { + let src_id = tok.get_src_id(); + + let name = flat_map.get_source(src_id); + let content = flat_map.get_source_contents(src_id); + + let (name, content) = name.zip(content)?; + Some(sourcemap_content_source( + name.clone().into(), + content.clone().into(), + )) + }); + } } token
← Back to Alerts View on GitHub →