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('