Skip to content

Commit 7b24eec

Browse files
committed
fix: stop a boundary crash leaking its message, and formatting one from throwing
Two hazards and four comments that described a mechanism the code does not have. The 403 / 401 / 404 catches rendered the thrown value into the response body in PRODUCTION, so a boundary that crashed leaked its error message to the client. The 500 path has always drawn that line, because a thrown message is not author-controlled. Those pages now show the detail in dev and the heading alone in prod. Both of those catches, and the dev branch of the default 500 page, also stringified the thrown value unguarded. String(Object.create(null)) throws on both runtimes, and all three run inside a catch whose job is to keep the response alive, so formatting the failure could escape the handler and turn a handled 500 into an unhandled one. One total formatter covers all three. The comments claimed the dedup key is the full stack. It is the stage plus the name, message and construction site, and the same block argues against a full-stack key three lines later. Two test comments said the same thing, one of them on the test that would fail under the mechanism it described.
1 parent e2ac6d9 commit 7b24eec

2 files changed

Lines changed: 95 additions & 11 deletions

File tree

packages/server/src/ssr/render.js

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,30 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
415415
}
416416

417417
/**
418-
* A per-request dedup key for a secondary boundary failure: the error's name,
419-
* message and FULL stack. Identity cannot be used, because a layout that
418+
* Render a thrown value as text for a DEV error page, safely.
419+
*
420+
* `String(err)` is not total: `String(Object.create(null))` throws on both
421+
* runtimes, and a getter on a subclass can throw too. Every caller here is
422+
* already inside a catch whose job is to keep the response alive, so an
423+
* uncaught throw while FORMATTING the failure escapes the handler and takes
424+
* the error page down with it, turning a handled 500 into an unhandled one.
425+
*
426+
* @param {unknown} err
427+
* @returns {string}
428+
*/
429+
function safeErrorText(err) {
430+
try {
431+
if (err instanceof Error) return String(err.stack || err.message);
432+
return String(err);
433+
} catch {
434+
return '[unprintable value]';
435+
}
436+
}
437+
438+
/**
439+
* A per-request dedup key for a secondary boundary failure: the STAGE it came
440+
* from plus the error's name, message and construction site. Identity cannot
441+
* be used, because a layout that
420442
* constructs its error yields a fresh object each time it is re-run, and the
421443
* whole point is to collapse exactly those repeats while letting a genuinely
422444
* different failure through.
@@ -443,6 +465,7 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
443465
* escape `ssrPage` and take the 500 page with it.
444466
*
445467
* @param {unknown} err
468+
* @param {string} stage Which attempt produced this throw. Part of the key.
446469
* @returns {string | null}
447470
*/
448471
function boundaryErrorKey(err, stage) {
@@ -656,7 +679,13 @@ async function ssrBoundaryHtml(file, heading, opts) {
656679
}
657680
}
658681
} catch (e) {
659-
body = `<h1>${heading}</h1><pre>${escapeHtml(String(e))}</pre>`;
682+
// Dev shows the failure; prod shows only the heading. The 500 path has
683+
// always drawn that line (a thrown error's message is not
684+
// author-controlled and must not reach the client), and these pages were
685+
// rendering it in production.
686+
body = opts.dev
687+
? `<h1>${heading}</h1><pre>${escapeHtml(safeErrorText(e))}</pre>`
688+
: `<h1>${heading}</h1>`;
660689
moduleUrls = [];
661690
}
662691
}
@@ -708,7 +737,9 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
708737
}
709738
}
710739
} catch (e) {
711-
body = `<h1>404: Not found</h1><pre>${escapeHtml(String(e))}</pre>`;
740+
body = opts.dev
741+
? `<h1>404: Not found</h1><pre>${escapeHtml(safeErrorText(e))}</pre>`
742+
: '<h1>404: Not found</h1>';
712743
moduleUrls = [];
713744
}
714745
}
@@ -1233,9 +1264,7 @@ export async function ssrPage(route, params, url, opts) {
12331264
// Default: dev shows stack, prod shows a terse message (no stack trace leaks).
12341265
console.error('[webjs] unhandled render error:', err);
12351266
const body = opts.dev
1236-
? `<h1>Server error</h1><pre style="white-space:pre-wrap">${escapeHtml(
1237-
err instanceof Error ? err.stack || err.message : String(err)
1238-
)}</pre>`
1267+
? `<h1>Server error</h1><pre style="white-space:pre-wrap">${escapeHtml(safeErrorText(err))}</pre>`
12391268
: `<h1>Server error</h1><p>Something went wrong. Please try again.</p>`;
12401269
return htmlResponse(
12411270
wrapInDocument(body, { metadata, moduleUrls: [], dev: opts.dev, nonce: errNonce }),

test/ssr/ssr.test.js

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2186,7 +2186,8 @@ test('boundary: one throwing layout is reported ONCE, not once per boundary', as
21862186
// The walk tries each boundary in the chain, and layoutsForBoundary selects
21872187
// by segment ancestry rather than boundary depth, so a root layout that
21882188
// throws fails EVERY attempt. Identity dedup would not help: a layout that
2189-
// constructs its error yields a fresh object per attempt, so the key is its name, message and stack.
2189+
// constructs its error yields a fresh object per attempt, so the key is the
2190+
// STAGE plus its name, message and construction site.
21902191
const { route, appDir } = makeBoundaryApp({
21912192
files: {
21922193
'layout.js': `export default function Root() { throw new Error('root-layout-boom'); }\n`,
@@ -2334,9 +2335,10 @@ test('boundary: a throw whose value cannot be stringified still degrades, never
23342335
});
23352336

23362337
test('boundary: two boundaries failing through ONE shared helper are both reported', async () => {
2337-
// The key is the whole stack, not just the frame where the Error was
2338-
// constructed. Keying on that one frame would make these two collide, and
2339-
// global-error's own crash would be swallowed by the earlier boundary's.
2338+
// These two share a construction site, so the error alone cannot separate
2339+
// them: it is the STAGE in the key (the boundary walk versus the
2340+
// global-error attempt) that keeps global-error's own crash from being
2341+
// swallowed by the earlier boundary's.
23402342
const { route, appDir } = makeBoundaryApp({
23412343
files: {
23422344
'boom.js': `export function boom() { throw new Error('shared-helper-boom'); }\n`,
@@ -2357,6 +2359,59 @@ test('boundary: two boundaries failing through ONE shared helper are both report
23572359
assert.equal(hits.length, 2, 'the boundary and global-error each report, despite one construction site');
23582360
});
23592361

2362+
test('boundary: an unprintable throw degrades in DEV too, and never escapes', async () => {
2363+
// The prod path was covered; dev formats the error into the page, and that
2364+
// formatting is itself inside the catch that keeps the response alive.
2365+
const { route, appDir } = makeBoundaryApp({
2366+
files: {
2367+
'page.js': `export default function Page() { throw Object.create(null); }\n`,
2368+
},
2369+
page: 'page.js',
2370+
layouts: [],
2371+
errors: [],
2372+
});
2373+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/'), { dev: true, appDir }));
2374+
assert.equal(resp.status, 500, 'dev degrades to the 500 page rather than throwing out of ssrPage');
2375+
assert.match(await resp.text(), /unprintable value/, 'and says so instead of crashing while formatting');
2376+
});
2377+
2378+
test('boundary: a crashing 403 boundary does not leak its error to the client in prod', async () => {
2379+
// The 500 path has always drawn this line; these pages were rendering the
2380+
// thrown message into the response body in production.
2381+
const { route, appDir } = makeBoundaryApp({
2382+
files: {
2383+
'admin/forbidden.js': `export default function F() { throw new Error('SECRET_DB_DSN_LEAK'); }\n`,
2384+
'admin/page.js': `import { forbidden } from ${JSON.stringify(WEBJS_MODULE_URL)};\nexport default function Page() { forbidden(); }\n`,
2385+
},
2386+
page: 'admin/page.js',
2387+
layouts: [],
2388+
forbiddens: ['admin/forbidden.js'],
2389+
});
2390+
const prod = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/admin'), { dev: false, appDir }));
2391+
assert.equal(prod.status, 403);
2392+
const prodBody = await prod.text();
2393+
assert.ok(!prodBody.includes('SECRET_DB_DSN_LEAK'), 'prod never renders the thrown message');
2394+
assert.ok(prodBody.includes('403'), 'but still identifies the status');
2395+
2396+
const dev = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/admin'), { dev: true, appDir }));
2397+
assert.match(await dev.text(), /SECRET_DB_DSN_LEAK/, 'dev still shows it, which is the point of dev');
2398+
});
2399+
2400+
test('boundary: an unprintable throw from a 403 boundary degrades, never escapes', async () => {
2401+
const { route, appDir } = makeBoundaryApp({
2402+
files: {
2403+
'admin/forbidden.js': `export default function F() { throw Object.create(null); }\n`,
2404+
'admin/page.js': `import { forbidden } from ${JSON.stringify(WEBJS_MODULE_URL)};\nexport default function Page() { forbidden(); }\n`,
2405+
},
2406+
page: 'admin/page.js',
2407+
layouts: [],
2408+
forbiddens: ['admin/forbidden.js'],
2409+
});
2410+
const resp = await SILENT(() => ssrPage(route, {}, new URL('http://localhost/admin'), { dev: true, appDir }));
2411+
assert.equal(resp.status, 403, 'it degrades rather than escaping ssrBoundaryHtml and ssrPage');
2412+
assert.match(await resp.text(), /unprintable value/);
2413+
});
2414+
23602415
test('boundary: a boundary response is never storable and never reduced', async () => {
23612416
// Two independent guarantees. The HTML cache refuses a non-200 outright, and
23622417
// the reduced X-Webjs-Have path is structurally unreachable: the boundary

0 commit comments

Comments
 (0)