From 6ff94d439e558af22cc7df3e3210253553f35aa7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:27:00 +0200 Subject: [PATCH] fix: pin Lite pages to their own inline scripts and guard the Stalwart bundle The static export's header policy has to allow inline scripts (the hydration scripts differ per page and one header cannot list them all), so any injected inline script ran. The Stalwart bundle had no policy at all: Stalwart sends no security headers for an Application, and nothing stopped another site from framing it. - Postbuild gives every page of the static target a `` CSP that allows exactly the page's own inline scripts; enforced together with the host's header, it takes away `'unsafe-inline'`. `verify` fails a page whose inline scripts are not all pinned. - The Stalwart entry document carries a `` CSP (no plugins, eval, foreign scripts or moved ``; its shells are rewritten at runtime, so hashes are not possible there) and refuses to run inside a frame of another origin. - The nginx and Caddy examples send the same CSP as `_headers`. --- lib/__tests__/lite-scripts.test.ts | 53 ++++++++++++++++++++ scripts/lite/lib.mjs | 80 ++++++++++++++++++++++++++++-- scripts/lite/postbuild.mjs | 31 ++++++++++-- scripts/lite/verify.mjs | 16 +++++- 4 files changed, 172 insertions(+), 8 deletions(-) diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts index f8b5bbc05..8f301e8f4 100644 --- a/lib/__tests__/lite-scripts.test.ts +++ b/lib/__tests__/lite-scripts.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { createHash } from 'node:crypto'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -24,7 +25,9 @@ import { buildReadme, buildRedirects, buildRootRedirect, + buildScriptHashCsp, buildStalwartEntry, + buildStalwartEntryCsp, buildStalwartManifest, buildStalwartReadme, buildZip, @@ -36,6 +39,7 @@ import { findSegmentPrefetchDirs, flightTextRowsWithNeedles, inlineJson, + inlineScriptHashes, installStalwartFetchShim, isReservedStalwartSegment, listZipEntries, @@ -54,6 +58,7 @@ import { stalwartRewriteIndex, stalwartZipEntryProblems, unexpectedApiStrings, + withScriptHashCsp, } from '../../scripts/lite/lib.mjs'; import { collectTestPaths, planRemovals, runPrepare } from '../../scripts/lite/prepare.mjs'; import { runPostbuild } from '../../scripts/lite/postbuild.mjs'; @@ -160,6 +165,35 @@ describe('lite build helpers', () => { expect(buildHeaders({ basePath: '/w', connectSrc: '*' })).toMatch(/^\/w\/\*/); }); + // The header policy has to allow inline scripts (the export ships hydration + // scripts and one header cannot list every page's hashes); each page pins + // its own inline scripts with a policy enforced alongside it. + it('pins each page to its own inline scripts', () => { + const sha = (text: string) => `'sha256-${createHash('sha256').update(text).digest('base64')}'`; + const html = [ + 'x', + '', + '', + '', + '', + '', + ].join(''); + + expect(inlineScriptHashes(html)).toEqual([ + sha('self.__next_f.push([1,"a"])'), + sha('import("./m.js")'), + sha('\nwindow.x = 1;\n'), + ]); + + const pinned = withScriptHashCsp(html); + const meta = ``; + expect(pinned.indexOf(meta)).toBe(pinned.indexOf('') + ''.length); + expect(pinned.indexOf(meta)).toBeLessThan(pinned.indexOf('')).toMatch(/^')).toMatch(/^]*>