mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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 `<meta>` 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 `<meta>` CSP (no plugins, eval, foreign scripts or moved `<base>`; 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`.
This commit is contained in:
@@ -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 <meta> 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 = [
|
||||
'<!DOCTYPE html><html><head><meta charSet="utf-8"/><title>x</title>',
|
||||
'<script src="/_next/static/chunks/a.js" async=""></script>',
|
||||
'<script>self.__next_f.push([1,"a"])</script>',
|
||||
'<script type="application/json">{"not":"code"}</script>',
|
||||
'<script type="module">import("./m.js")</script>',
|
||||
'</head><body><script>\r\nwindow.x = 1;\r\n</script></body></html>',
|
||||
].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 = `<meta http-equiv="Content-Security-Policy" content="${buildScriptHashCsp(inlineScriptHashes(html))}"/>`;
|
||||
expect(pinned.indexOf(meta)).toBe(pinned.indexOf('<meta charSet="utf-8"/>') + '<meta charSet="utf-8"/>'.length);
|
||||
expect(pinned.indexOf(meta)).toBeLessThan(pinned.indexOf('<script'));
|
||||
expect(buildScriptHashCsp([])).toBe("script-src 'self'; object-src 'none'; base-uri 'self'");
|
||||
expect(withScriptHashCsp('<html><head><script>1</script></head></html>')).toMatch(/^<html><head><meta http-equiv/);
|
||||
expect(withScriptHashCsp('<html><script>1</script></html>')).toMatch(/^<html><meta http-equiv="Content-Security-Policy"[^>]*><script>/);
|
||||
});
|
||||
|
||||
it('writes a 404 shim that parks deep links for known locale/surface pairs only', () => {
|
||||
const html = buildNotFoundShim({ basePath: '/webmail', locales: ['de', 'en'] });
|
||||
expect(html).toContain('var base = "/webmail"');
|
||||
@@ -186,6 +220,10 @@ describe('lite build helpers', () => {
|
||||
expect(nginxSub).toContain('default_type application/manifest+json');
|
||||
expect(buildCaddyExample({ basePath: '' })).toContain('try_files {path} {path}/ {path}/index.html /{re.surface.1}/{re.surface.2}/index.html');
|
||||
expect(buildCaddyExample({ basePath: '/w' })).toContain('rewrite @notfound /w/404.html');
|
||||
// The examples send the same policy as _headers, for the configured server.
|
||||
const nginxCsp = buildNginxExample({ basePath: '', connectSrc: 'https://m.example' });
|
||||
expect(nginxCsp.match(/add_header Content-Security-Policy "[^"]*connect-src 'self' https:\/\/m\.example[^"]*" always;/g)).toHaveLength(2);
|
||||
expect(buildCaddyExample({ basePath: '', connectSrc: 'https://m.example' })).toMatch(/Content-Security-Policy "[^"]*connect-src 'self' https:\/\/m\.example/);
|
||||
expect(buildReadme({ version: '1.10.0', commit: 'abc1234', basePath: '/w', locales: ['en'] })).toContain('unzip into `<web root>/w`');
|
||||
const readme = buildReadme({ version: '1.10.0', commit: 'abc1234', basePath: '/w', locales: ['en'], jmapServerUrl: 'https://m.example', demoMode: true });
|
||||
expect(readme).toContain('# Bulwark Lite 1.10.0 (abc1234)');
|
||||
@@ -545,6 +583,21 @@ describe('Stalwart target: the entry document', () => {
|
||||
expect(script).toContain('var BUILD_ID = "1.10.0-abc-x"');
|
||||
});
|
||||
|
||||
// Stalwart sends no security headers for an Application: the entry carries
|
||||
// its own policy and refuses to run in another origin's frame.
|
||||
it('carries a CSP ahead of its script and refuses foreign frames', () => {
|
||||
const csp = buildStalwartEntryCsp();
|
||||
expect(csp).toContain("script-src 'self' 'unsafe-inline'");
|
||||
expect(csp).toContain("object-src 'none'");
|
||||
expect(csp).toContain("base-uri 'self'");
|
||||
expect(csp).not.toContain('frame-ancestors'); // ignored in a <meta>
|
||||
const meta = `<meta http-equiv="Content-Security-Policy" content="${csp}" />`;
|
||||
expect(html.indexOf(meta)).toBeGreaterThan(-1);
|
||||
expect(html.indexOf(meta)).toBeLessThan(html.indexOf('<script>'));
|
||||
expect(html).toContain('window.top.location.origin !== location.origin');
|
||||
expect(html).toMatch(/if \(foreignFrame\) return fail\(/);
|
||||
});
|
||||
|
||||
it('survives both Stalwart rewrites, including the pre-0.16.19 replace-all', () => {
|
||||
const current = stalwartRewriteIndex(html, 'webmail', 'my"client');
|
||||
expect(current).toContain('<base href="/webmail/" />');
|
||||
|
||||
+76
-4
@@ -1,6 +1,7 @@
|
||||
// Shared, side-effect-free helpers for the Lite build scripts. Kept separate
|
||||
// so lib/__tests__/lite-scripts.test.ts can exercise them without touching
|
||||
// the filesystem or spawning `next build`.
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -356,7 +357,11 @@ export function buildRedirects({ basePath = "", locales, surfaces = LITE_SURFACE
|
||||
export function buildCsp({ connectSrc = "*" } = {}) {
|
||||
return [
|
||||
"default-src 'self'",
|
||||
// The static export ships inline hydration scripts, so no nonce is possible.
|
||||
// The static export ships inline hydration scripts, so no nonce is possible
|
||||
// and one header cannot list every page's hashes. Each page of the static
|
||||
// target carries its own <meta> policy that allows exactly its inline
|
||||
// scripts (withScriptHashCsp); both policies apply, so this only matters
|
||||
// where that meta is missing.
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
@@ -371,6 +376,59 @@ export function buildCsp({ connectSrc = "*" } = {}) {
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
/** The `'sha256-...'` source for every inline script in an HTML document. */
|
||||
export function inlineScriptHashes(html) {
|
||||
const hashes = new Set();
|
||||
for (const [, attrs, body] of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)) {
|
||||
if (/\bsrc\s*=/i.test(attrs)) continue;
|
||||
// Data blocks (JSON, templates) never execute.
|
||||
const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
|
||||
if (type && type !== "module" && !/^(text|application)\/(x-)?(java|ecma)script$/.test(type)) continue;
|
||||
// Browsers normalise newlines before the script text is hashed.
|
||||
const text = body.replace(/\r\n?/g, "\n");
|
||||
hashes.add(`'sha256-${createHash("sha256").update(text, "utf8").digest("base64")}'`);
|
||||
}
|
||||
return [...hashes];
|
||||
}
|
||||
|
||||
/**
|
||||
* The page-level policy of a static-target HTML file: its own inline scripts
|
||||
* and scripts from this origin, nothing else. Enforced together with the
|
||||
* host's header policy, it takes away the header's 'unsafe-inline'.
|
||||
*/
|
||||
export function buildScriptHashCsp(hashes) {
|
||||
return [`script-src 'self'${hashes.map((h) => ` ${h}`).join("")}`, "object-src 'none'", "base-uri 'self'"].join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* `html` with a <meta> CSP naming its inline script hashes, ahead of every
|
||||
* script: right after <meta charset> (which has to stay within the first
|
||||
* 1024 bytes), else as early in the document as possible.
|
||||
*/
|
||||
export function withScriptHashCsp(html) {
|
||||
const meta = `<meta http-equiv="Content-Security-Policy" content="${buildScriptHashCsp(inlineScriptHashes(html))}"/>`;
|
||||
// Without a <head> the parser still places a leading <meta> in the implied one.
|
||||
const anchor = /<meta\s+charset\s*=[^>]*>/i.exec(html) ?? /<head\b[^>]*>/i.exec(html)
|
||||
?? /<html\b[^>]*>/i.exec(html) ?? /<!doctype[^>]*>/i.exec(html);
|
||||
const at = anchor ? anchor.index + anchor[0].length : 0;
|
||||
return html.slice(0, at) + meta + html.slice(at);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy of the Stalwart target's entry document. Stalwart sends no
|
||||
* security headers for an Application, so this <meta> is all there is. The
|
||||
* shells it writes in are rewritten for the mount at runtime, which rules
|
||||
* out build-time hashes; it still forbids plugins, eval, foreign scripts and
|
||||
* a moved <base>. frame-ancestors cannot be set from a <meta>: the entry
|
||||
* refuses to run in a frame of another origin instead.
|
||||
*/
|
||||
export function buildStalwartEntryCsp() {
|
||||
return buildCsp({ connectSrc: "*" })
|
||||
.split("; ")
|
||||
.filter((directive) => !directive.startsWith("frame-ancestors"))
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/** Security headers for hosts that read a `_headers` file. */
|
||||
export function buildHeaders({ basePath = "", connectSrc = "*" }) {
|
||||
const csp = buildCsp({ connectSrc });
|
||||
@@ -398,8 +456,9 @@ export function exampleDocRoot(basePath = "") {
|
||||
return { root: "/var/www/bulwark-lite", files: `/var/www/bulwark-lite${basePath}` };
|
||||
}
|
||||
|
||||
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES }) {
|
||||
export function buildNginxExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*" }) {
|
||||
const location = basePath ? `${basePath}/` : "/";
|
||||
const csp = buildCsp({ connectSrc });
|
||||
const { root, files } = exampleDocRoot(basePath);
|
||||
const surfaceAlternation = surfaces.join("|");
|
||||
return `# Bulwark Lite - nginx example. Unzip the archive into ${files}
|
||||
@@ -413,7 +472,9 @@ server {
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
# See _headers for a Content-Security-Policy that matches your JMAP server.
|
||||
# connect-src names the JMAP server this bundle was built for. Every page
|
||||
# also carries a <meta> policy that allows only its own inline scripts.
|
||||
add_header Content-Security-Policy "${csp}" always;
|
||||
|
||||
# Hashed assets never change. (add_header inside a location replaces the
|
||||
# inherited set, so the security headers are repeated here.)
|
||||
@@ -422,6 +483,7 @@ server {
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
add_header Content-Security-Policy "${csp}" always;
|
||||
}
|
||||
|
||||
# Older mime.types files do not know the PWA manifest extension.
|
||||
@@ -525,8 +587,9 @@ ${securityHeaders(" ")}
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES }) {
|
||||
export function buildCaddyExample({ basePath = "", surfaces = LITE_SURFACES, connectSrc = "*" }) {
|
||||
const { root, files } = exampleDocRoot(basePath);
|
||||
const csp = buildCsp({ connectSrc });
|
||||
const surfaceAlternation = surfaces.join("|");
|
||||
return `# Bulwark Lite - Caddy example. Unzip the archive into ${files}
|
||||
# so that ${basePath}/index.html is served at ${basePath || ""}/.
|
||||
@@ -538,6 +601,8 @@ webmail.example.com {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
# connect-src names the JMAP server this bundle was built for.
|
||||
Content-Security-Policy "${csp}"
|
||||
}
|
||||
|
||||
@manifest path ${basePath}/manifest.webmanifest
|
||||
@@ -1005,6 +1070,12 @@ export function buildStalwartEntry({ locales, shells, defaultLocale = "en", buil
|
||||
if (chosen === "dark" || chosen === "light") document.documentElement.className = chosen;
|
||||
} catch (e) {}
|
||||
|
||||
// Stalwart sends no X-Frame-Options or frame-ancestors for an Application:
|
||||
// inside another origin's frame the app would be open to clickjacking.
|
||||
var foreignFrame = false;
|
||||
try { foreignFrame = window.top !== window.self && window.top.location.origin !== location.origin; } catch (e) { foreignFrame = true; }
|
||||
if (foreignFrame) return fail("Bulwark Webmail cannot be shown inside another site's page. Open it directly.");
|
||||
|
||||
function fail(message) {
|
||||
function show() {
|
||||
var box = document.getElementById("bulwark-lite-boot");
|
||||
@@ -1088,6 +1159,7 @@ export function buildStalwartEntry({ locales, shells, defaultLocale = "en", buil
|
||||
<html lang="en" ${STALWART_ENTRY_MARKER}="">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${buildStalwartEntryCsp()}" />
|
||||
${STALWART_BASE_HREF_LITERAL} />
|
||||
${STALWART_OAUTH_META_LITERAL} />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
// entry document Stalwart serves for every route, config/policy/manifest that
|
||||
// follow the runtime mount, no host snippets, no 404.html - and packs out/
|
||||
// into bulwark-lite-stalwart.zip.
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
STALWART_EXCLUDED_DIRS, STALWART_EXCLUDED_FILES, STALWART_ZIP_NAME,
|
||||
buildCaddyExample, buildConnectorCapabilities, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect,
|
||||
buildStalwartEntry, buildStalwartManifest, buildStalwartReadme, buildZip, collectZipEntries, discoverBuiltLocales, discoverShellDirs, findSegmentPrefetchDirs, isMainModule, makeBuildId, normalizeBasePath, parseLiteTarget, resolveRepoRoot,
|
||||
withScriptHashCsp,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
@@ -47,6 +48,12 @@ export function runPostbuild({ root = repoRoot, env = process.env, log = console
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
writeFileSync(join(outDir, name), content);
|
||||
}
|
||||
// The Stalwart target rewrites its shells at runtime, so their scripts
|
||||
// cannot be hashed at build time (see buildStalwartEntryCsp).
|
||||
if (target === "static") {
|
||||
const pages = applyScriptHashCsp(outDir);
|
||||
log(`[lite] pinned the inline scripts of ${pages} pages with a <meta> CSP`);
|
||||
}
|
||||
log(`[lite] wrote ${Object.keys(files).length} files into out/ (${target} target, ${locales.length} locales, base path ${target === "stalwart" ? "runtime" : basePath || "/"})`);
|
||||
|
||||
let zipPath = null;
|
||||
@@ -74,8 +81,8 @@ function staticFiles({ config, basePath, locales, defaultLocale, version, commit
|
||||
"404.html": buildNotFoundShim({ basePath, locales }),
|
||||
"_redirects": buildRedirects({ basePath, locales }),
|
||||
"_headers": buildHeaders({ basePath, connectSrc }),
|
||||
"nginx.conf.example": buildNginxExample({ basePath }),
|
||||
"Caddyfile.example": buildCaddyExample({ basePath }),
|
||||
"nginx.conf.example": buildNginxExample({ basePath, connectSrc }),
|
||||
"Caddyfile.example": buildCaddyExample({ basePath, connectSrc }),
|
||||
"LITE-README.md": buildReadme({ version, commit, basePath, locales, jmapServerUrl: config.jmapServerUrl, demoMode: config.demoMode }),
|
||||
};
|
||||
}
|
||||
@@ -102,6 +109,24 @@ function stalwartFiles({ outDir, config, locales, defaultLocale, version, commit
|
||||
};
|
||||
}
|
||||
|
||||
/** Add each HTML file's script-hash policy (withScriptHashCsp); returns how many. */
|
||||
export function applyScriptHashCsp(outDir) {
|
||||
let pages = 0;
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== "_next") walk(path);
|
||||
} else if (entry.name.endsWith(".html")) {
|
||||
writeFileSync(path, withScriptHashCsp(readFileSync(path, "utf8")));
|
||||
pages++;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(outDir);
|
||||
return pages;
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
try {
|
||||
runPostbuild();
|
||||
|
||||
+15
-1
@@ -10,7 +10,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
LITE_PENDING_PATH_KEY, STALWART_EXCLUDED_FILES, STALWART_MAX_BUNDLE_BYTES, STALWART_SAFE_BUNDLE_BYTES, STALWART_ZIP_NAME,
|
||||
collectApiStrings, collectZipEntries, discoverBuiltLocales, flightTextRowsWithNeedles, isMainModule, listZipEntries, parseLiteTarget, resolveRepoRoot, stalwartEntryProblems, stalwartZipEntryProblems, unexpectedApiStrings,
|
||||
collectApiStrings, collectZipEntries, discoverBuiltLocales, flightTextRowsWithNeedles, inlineScriptHashes, isMainModule, listZipEntries, parseLiteTarget, resolveRepoRoot, stalwartEntryProblems, stalwartZipEntryProblems, unexpectedApiStrings,
|
||||
} from "./lib.mjs";
|
||||
|
||||
const repoRoot = resolveRepoRoot(import.meta.url);
|
||||
@@ -63,6 +63,20 @@ export function verifyExport({ root = repoRoot, target, safeBundleBytes = STALWA
|
||||
if (existsSync(notFound) && !readFileSync(notFound, "utf8").includes(LITE_PENDING_PATH_KEY)) {
|
||||
problems.push("out/404.html is not the Lite shim (deep links on hosts without rewrites would dead-end); run postbuild");
|
||||
}
|
||||
// Every page pins its inline scripts (postbuild applyScriptHashCsp); a
|
||||
// page changed afterwards would load with its hydration scripts blocked.
|
||||
for (const locale of locales) {
|
||||
for (const surface of ["mail", "calendar", "contacts", "files", "settings", "login"]) {
|
||||
const page = join(outDir, locale, surface, "index.html");
|
||||
if (!existsSync(page)) continue;
|
||||
const html = readFileSync(page, "utf8");
|
||||
const policy = /<meta http-equiv="Content-Security-Policy" content="([^"]*)"/.exec(html)?.[1];
|
||||
if (!policy) problems.push(`out/${locale}/${surface}/index.html carries no script policy; run postbuild`);
|
||||
else if (inlineScriptHashes(html).some((hash) => !policy.includes(hash))) {
|
||||
problems.push(`out/${locale}/${surface}/index.html has an inline script its policy does not allow`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const apiStrings = collectApiStrings(join(outDir, "_next", "static"));
|
||||
|
||||
Reference in New Issue
Block a user