Every built page carries a CSP meta tag admitting only the app's own hashed scripts — no remote script origins, no inline execution beyond the hashed island bootstraps — with 'wasm-unsafe-eval' for the sync engine and object-src/base-uri/worker-src locked down. The build unions the per-page policies into dist/lofi-build.json and a host-header snippet (dist/_headers.example), reports the shipped policy, and warns on weakenings without gating; preview serves the union as a real header on pages and the service worker so the header path is exercised before deployment. Tuning rides the environment: LOFI_CSP_SCRIPT_SRC/STYLE_SRC/DIRECTIVES extend, LOFI_CSP_CONNECT_SRC pins a fixed sink origin (not defaulted — the sync location is user data), LOFI_CSP=off disables with a report. CSP is inactive under the Vite dev server by Astro's design.
- commit
- 554498eb174e5a876a7634edbad9dd93a1bad4a4
- parent
- ea2d0afd440a0233668bf8ca07a5327da14059af
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | docs/ | 20 | +++++++ |
| M | docs/ | 7 | ++ |
| M | package/ | 26 | +++++++++ |
| M | package/ | 38 | +++++++++++++ |
| M | package/ | 14 | +++- |
| A | package/ | 87 | ++++++++++++++++++++++++++++++ |
| A | package/ | 47 | ++++++++++++++++ |
| M | package/ | 23 | ++++++++- |
| M | package/ | 8 | ++- |
@@ -164,6 +164,26 @@ The app is the only thing that needs hosting: sync is optional infrastructure th
164164 a managed Jazz app or at [a self-hosted sync node](/node) they run themselves — the deployment does165165 not change either way.166166 167+### Content-Security-Policy168+169+Every built page carries a strict Content-Security-Policy in a `<meta>` tag: scripts and styles are170+admitted only from the app's own origin plus the exact hashes of the inline island bootstraps, with171+`'wasm-unsafe-eval'` for the sync engine's WebAssembly and `object-src 'none'`, `base-uri 'self'`,172+`worker-src 'self'` alongside. The meta tag enforces on every host, including hosts that cannot set173+response headers — on those, it is the effective mechanism. Where your host supports response174+headers, mirror the policy as a real `Content-Security-Policy` header using the generated175+`dist/_headers.example`: a header additionally covers `frame-ancestors` (ignored in meta by spec)176+and governs the service worker's own execution. `deno task preview` sends the header so the header177+path is exercised before deployment.178+179+Tuning happens through the environment at build time: `LOFI_CSP_SCRIPT_SRC`, `LOFI_CSP_STYLE_SRC`,180+and `LOFI_CSP_DIRECTIVES` extend the policy (space-separated sources; semicolon-separated181+directives), `LOFI_CSP_CONNECT_SRC` pins the sync origin for apps with a fixed managed sink — note182+that pinning it breaks runtime enrollment to any other node, which is why it is not set by default183+(the sync location is user data, not build configuration) — and `LOFI_CSP=off` disables the policy184+entirely. The build reports the shipped policy and warns on weakenings (`'unsafe-inline'`, remote185+script origins, missing pages, or a disabled policy); what ships is the author's call.186+167187 ### Offline cache policy168188 169189 The build's precache manifest contains required shell resources only. Mutable shell assets (HTML,@@ -67,6 +67,13 @@ theft degrades from silent to prompted, and a prompt the user did not initiate i
6767 decline. At-rest theft (disk images, backups, storage exfiltration) gets the stronger guarantee:6868 sealed records are ciphertext without the device key or the passkey.6969 70+Generated apps also narrow the surface that creates same-origin script in the first place: every71+built page carries a Content-Security-Policy that admits only the app's own hashed scripts — no72+remote script origins, no inline execution beyond the hashed island bootstraps. The build reports73+the policy and warns on weakenings; `LOFI_CSP=off` and the extension points are documented in the74+deployment guide. A compromised dependency or injected markup then fails to execute rather than75+inheriting the page's capability.76+7077 ## Where the lines move7178 7279 - **Shared-field encryption** is the designed next step: distributing a field key wrapped to each@@ -186,6 +186,31 @@ const managedJazzServer = process.env.LOFI_SKIP_JAZZ_MANAGED === "1"
186186 ? false187187 : { allowLocalFirstAuth: true };188188 const deploymentBase = process.env.LOFI_BASE_PATH || "/";189+// Content-Security-Policy: on by default; Astro emits a per-page meta tag190+// with hashes for its inline island scripts. 'wasm-unsafe-eval' admits the191+// Jazz engine's WebAssembly.instantiate; connect-src is absent by default192+// because the sync location is user data enrolled at runtime, not build193+// configuration. LOFI_CSP=off disables (reported by the build, not blocked).194+const cspOff = (process.env.LOFI_CSP || "").trim() === "off";195+const cspList = (name) => (process.env[name] || "").split(" ").filter(Boolean);196+const cspExtraDirectives = (process.env.LOFI_CSP_DIRECTIVES || "")197+ .split(";").map((entry) => entry.trim()).filter(Boolean);198+const cspConnect = (process.env.LOFI_CSP_CONNECT_SRC || "").trim();199+const cspSecurity = cspOff ? {} : {200+ csp: {201+ scriptDirective: {202+ resources: ["'self'", "'wasm-unsafe-eval'", ...cspList("LOFI_CSP_SCRIPT_SRC")],203+ },204+ styleDirective: { resources: ["'self'", ...cspList("LOFI_CSP_STYLE_SRC")] },205+ directives: [206+ "object-src 'none'",207+ "base-uri 'self'",208+ "worker-src 'self'",209+ ...(cspConnect ? ["connect-src " + cspConnect] : []),210+ ...cspExtraDirectives,211+ ],212+ },213+};189214 190215 if (Boolean(cloudAppId) !== Boolean(cloudServerUrl)) {191216 throw new Error(@@ -201,6 +226,7 @@ if (cloudAppId && cloudServerUrl) {
201226 export default defineConfig({202227 base: deploymentBase,203228 output: "static",229+ security: cspSecurity,204230 trailingSlash: "always",205231 integrations: [preact({ compat: true, devtools: true })],206232 vite: {@@ -30,6 +30,12 @@ import {
3030 screenshotAssetPaths,3131 } from "../tooling/pwa-validation.ts";3232 import { generateSchemaCompatManifest } from "../tooling/schema-manifest.ts";33+import {34+ cspPolicyWarnings,35+ mergeCspPolicies,36+ parseCspMeta,37+ renderHeadersSnippet,38+} from "../tooling/csp.ts";3339 import { runDeno } from "../tooling/process.ts";3440 import { exitOnFailure, validatedCommandEnvironment } from "./shared.ts";3541 @@ -117,10 +123,42 @@ const engineTags = await Promise.all(
117123 )118124 ),119125 );126+const cspPolicies: string[] = [];127+const cspMissing: string[] = [];120128 for (const path of distFiles.filter((file) => extname(file) === ".html")) {121129 const html = await Deno.readTextFile(join("dist", path));122130 const injected = injectHeadTags(html, engineTags);123131 if (injected !== html) await Deno.writeTextFile(join("dist", path), injected);132+ const policy = parseCspMeta(injected);133+ if (policy === null) cspMissing.push(path);134+ else cspPolicies.push(policy);135+}136+137+// One deployable policy for the whole site: the per-page meta tags enforce138+// it already; the union feeds the preview server's real header and the139+// host-header snippet. Reported, never gated.140+const cspUnion = cspPolicies.length > 0 ? mergeCspPolicies(cspPolicies) : null;141+if (cspUnion !== null) {142+ const buildInfo = JSON.parse(await Deno.readTextFile("dist/lofi-build.json"));143+ buildInfo.csp = cspUnion;144+ await Deno.writeTextFile("dist/lofi-build.json", `${JSON.stringify(buildInfo)}\n`);145+ await Deno.writeTextFile("dist/_headers.example", renderHeadersSnippet(cspUnion));146+ const hashCount = (cspUnion.match(/'sha\d{3}-/g) ?? []).length;147+ console.log(148+ `lofi build: CSP script-src 'self' 'wasm-unsafe-eval' + ${hashCount} hashes ` +149+ "(meta-enforced; mirror as a header where your host supports it — see dist/_headers.example)",150+ );151+ for (const finding of cspPolicyWarnings(cspUnion)) console.warn(`warning: ${finding}`);152+ if (cspMissing.length > 0) {153+ console.warn(154+ `warning: ${cspMissing.length} built page(s) carry no CSP meta tag: ${cspMissing.join(", ")}`,155+ );156+ }157+} else {158+ console.warn(159+ "warning: the build carries no Content-Security-Policy (LOFI_CSP=off or no pages); " +160+ "same-origin script is the threat model's shaping attacker, so prefer the default policy",161+ );124162 }125163 126164 const screenshotPaths = screenshotAssetPaths(productionManifest, environment.LOFI_BASE_PATH);@@ -18,7 +18,7 @@ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
1818 Deno.exit(2);1919 }2020 21-let identity: { lofiVersion?: string; sourceHash?: string; basePath?: string };21+let identity: { lofiVersion?: string; sourceHash?: string; basePath?: string; csp?: string };2222 try {2323 identity = JSON.parse(await Deno.readTextFile("dist/lofi-build.json"));2424 } catch {@@ -48,9 +48,15 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
4848 const path = join("dist", relative);4949 try {5050 const body = await Deno.readFile(path);51- return new Response(request.method === "HEAD" ? null : body, {52- headers: { "content-type": productionContentType(path) },53- });51+ const contentType = productionContentType(path);52+ const headers: Record<string, string> = { "content-type": contentType };53+ // The built pages enforce the policy via meta; preview also sends the54+ // real header so the header path — including frame-ancestors and the55+ // service worker's own execution — is exercised before deployment.56+ if (identity.csp && (contentType.startsWith("text/html") || relative === "sw.js")) {57+ headers["content-security-policy"] = `${identity.csp}; frame-ancestors 'none'`;58+ }59+ return new Response(request.method === "HEAD" ? null : body, { headers });5460 } catch (error) {5561 if (error instanceof Deno.errors.NotFound) return new Response("Not found", { status: 404 });5662 throw error;@@ -0,0 +1,87 @@
1+/**2+ * Content-Security-Policy tooling for the build: parse the per-page meta3+ * policies Astro emits, union them into one deployable policy (hash unions4+ * are additive allowances, so a single merged policy is valid for every5+ * page), and report weakenings. The build reports and never gates — which6+ * directives an app ships is the author's call; only the information is7+ * mandatory.8+ *9+ * @module10+ */11+12+const META_PATTERN = /<meta\s+http-equiv="content-security-policy"\s+content="([^"]*)"\s*\/?>/i;13+14+/** The CSP policy of one built page, or null when the page carries none. */15+export function parseCspMeta(html: string): string | null {16+ const match = html.match(META_PATTERN);17+ if (!match) return null;18+ return match[1].replaceAll(""", '"').replaceAll("'", "'").replaceAll("&", "&")19+ .trim();20+}21+22+function parsePolicy(policy: string): Map<string, Set<string>> {23+ const directives = new Map<string, Set<string>>();24+ for (const entry of policy.split(";")) {25+ const trimmed = entry.trim();26+ if (!trimmed) continue;27+ const [name, ...values] = trimmed.split(/\s+/);28+ const existing = directives.get(name) ?? new Set<string>();29+ for (const value of values) existing.add(value);30+ directives.set(name, existing);31+ }32+ return directives;33+}34+35+/** Unions per-page policies into one policy valid for every page. */36+export function mergeCspPolicies(policies: readonly string[]): string {37+ const merged = new Map<string, Set<string>>();38+ for (const policy of policies) {39+ for (const [name, values] of parsePolicy(policy)) {40+ const existing = merged.get(name) ?? new Set<string>();41+ for (const value of values) existing.add(value);42+ merged.set(name, existing);43+ }44+ }45+ return [...merged.entries()]46+ .map(([name, values]) => [name, ...values].join(" "))47+ .join("; ");48+}49+50+/** Findings worth surfacing about a policy; empty means nothing to report. */51+export function cspPolicyWarnings(policy: string): string[] {52+ const warnings: string[] = [];53+ const directives = parsePolicy(policy);54+ const scripts = directives.get("script-src") ?? new Set<string>();55+ if (scripts.has("'unsafe-inline'")) {56+ warnings.push("script-src allows 'unsafe-inline', which disables the injection protection");57+ }58+ if (scripts.has("'unsafe-eval'")) {59+ warnings.push("script-src allows 'unsafe-eval'");60+ }61+ const remote = [...scripts].filter((value) =>62+ !value.startsWith("'") && value !== "blob:" && value !== "data:"63+ );64+ if (remote.length > 0) {65+ warnings.push(`script-src admits remote origins: ${remote.join(", ")}`);66+ }67+ return warnings;68+}69+70+/**71+ * A commented header snippet for hosts that support response headers. The72+ * meta tag already enforces the policy everywhere; a real header adds the73+ * directives a meta tag cannot carry (frame-ancestors) and governs the74+ * service worker's own execution.75+ */76+export function renderHeadersSnippet(policy: string): string {77+ return [78+ "# Content-Security-Policy for hosts that support response headers.",79+ "# The built pages already enforce this policy via a meta tag; a real",80+ "# header additionally covers frame-ancestors (ignored in meta by spec)",81+ "# and the service worker script itself (sw.js). Adapt the syntax to",82+ "# your host (this file uses the Netlify/Cloudflare _headers format).",83+ "/*",84+ ` Content-Security-Policy: ${policy}; frame-ancestors 'none'`,85+ "",86+ ].join("\n");87+}@@ -0,0 +1,47 @@
1+// CSP tooling contract: meta parsing, policy union, warning analysis, and2+// the header snippet.3+import { cspPolicyWarnings, mergeCspPolicies, parseCspMeta, renderHeadersSnippet } from "./csp.ts";4+import { assert } from "../runtime/test-assert.ts";5+6+Deno.test("parseCspMeta extracts and entity-decodes the policy", () => {7+ const html = `<html><head><meta http-equiv="content-security-policy" content="script-src ` +8+ `'self' 'sha256-abc'; style-src 'self'"></head><body></body></html>`;9+ const policy = parseCspMeta(html);10+ assert(11+ policy === "script-src 'self' 'sha256-abc'; style-src 'self'",12+ `unexpected parse: ${policy}`,13+ );14+ assert(parseCspMeta("<html><head></head></html>") === null, "no meta must parse to null");15+});16+17+Deno.test("mergeCspPolicies unions directive values and dedupes", () => {18+ const merged = mergeCspPolicies([19+ "script-src 'self' 'sha256-a'; style-src 'self'",20+ "script-src 'self' 'sha256-b'; style-src 'self' 'sha256-s'; object-src 'none'",21+ ]);22+ assert(23+ merged === "script-src 'self' 'sha256-a' 'sha256-b'; style-src 'self' 'sha256-s'; " +24+ "object-src 'none'",25+ `unexpected union: ${merged}`,26+ );27+});28+29+Deno.test("cspPolicyWarnings flags weakenings and stays quiet otherwise", () => {30+ const clean = cspPolicyWarnings(31+ "script-src 'self' 'wasm-unsafe-eval' 'sha256-a'; object-src 'none'",32+ );33+ assert(clean.length === 0, `clean policy warned: ${clean.join(" | ")}`);34+ const inline = cspPolicyWarnings("script-src 'self' 'unsafe-inline'");35+ assert(inline.some((w) => w.includes("unsafe-inline")), "unsafe-inline must warn");36+ const remote = cspPolicyWarnings("script-src 'self' https://cdn.example.com");37+ assert(remote.some((w) => w.includes("cdn.example.com")), "remote origins must warn");38+});39+40+Deno.test("the headers snippet carries the policy and frame-ancestors", () => {41+ const snippet = renderHeadersSnippet("script-src 'self'");42+ assert(43+ snippet.includes("Content-Security-Policy: script-src 'self'; frame-ancestors 'none'"),44+ "snippet must render the full header",45+ );46+ assert(snippet.startsWith("#"), "snippet must lead with its commentary");47+});@@ -2,7 +2,14 @@ import { normalizeDeploymentBase } from "./base-path.ts";
22 33 export const clientEnvironmentNames = ["JAZZ_APP_ID", "JAZZ_SERVER_URL"] as const;44 export const serverEnvironmentNames = ["JAZZ_ADMIN_SECRET", "BACKEND_SECRET"] as const;5-export const deploymentEnvironmentNames = ["LOFI_BASE_PATH"] as const;5+export const cspEnvironmentNames = [6+ "LOFI_CSP",7+ "LOFI_CSP_SCRIPT_SRC",8+ "LOFI_CSP_STYLE_SRC",9+ "LOFI_CSP_CONNECT_SRC",10+ "LOFI_CSP_DIRECTIVES",11+] as const;12+export const deploymentEnvironmentNames = ["LOFI_BASE_PATH", ...cspEnvironmentNames] as const;613 export const environmentNames = [714 ...clientEnvironmentNames,815 ...serverEnvironmentNames,@@ -13,6 +20,8 @@ interface EnvironmentResultBase {
1320 presentClientNames: string[];1421 presentServerNames: string[];1522 basePath: string;23+ /** Content-Security-Policy tuning passed through to the build verbatim. */24+ csp: Record<string, string>;1625 warnings: string[];1726 }1827 @@ -55,6 +64,16 @@ export function validateEnvironment(
5564 errors.push(error instanceof Error ? error.message : String(error));5665 }5766 67+ const csp: Record<string, string> = {};68+ for (const name of cspEnvironmentNames) {69+ if (isPresent(env, name)) csp[name] = env[name].trim();70+ }71+ if (csp.LOFI_CSP !== undefined && csp.LOFI_CSP !== "off") {72+ errors.push(73+ 'LOFI_CSP only accepts "off" (the default policy is on); unset it or set LOFI_CSP=off.',74+ );75+ }76+5877 if (5978 presentClientNames.length > 0 && presentClientNames.length !== clientEnvironmentNames.length6079 ) {@@ -85,6 +104,7 @@ export function validateEnvironment(
85104 presentClientNames: [...presentClientNames],86105 presentServerNames: [...presentServerNames],87106 basePath,107+ csp,88108 warnings,89109 };90110 if (errors.length > 0) return { ...base, ok: false, mode: "invalid", errors };@@ -200,6 +220,7 @@ export function childEnvironment(
200220 environment.JAZZ_SERVER_URL = "";201221 }202222 environment.LOFI_BASE_PATH = validation.basePath;223+ for (const [name, value] of Object.entries(validation.csp)) environment[name] = value;203224 environment.JAZZ_ADMIN_SECRET = "";204225 environment.BACKEND_SECRET = "";205226 return environment;@@ -922,7 +922,13 @@ async function filesUnder(root: string): Promise<string[]> {
922922 return files.sort();923923 }924924 925-const precacheExcludedPaths = new Set(["lofi-build.json", "lofi-precache.json", "sw.js"]);925+// _headers.example is deployment documentation, not a shell resource.926+const precacheExcludedPaths = new Set([927+ "_headers.example",928+ "lofi-build.json",929+ "lofi-precache.json",930+ "sw.js",931+]);926932 927933 /** Returns manifest screenshot paths, which are install presentation rather than shell resources. */928934 export function screenshotAssetPaths(manifest: unknown, deploymentBase = "/"): string[] {