#!/usr/bin/env node // Checks the static site in build/ after `npm run build` (plan ยง8, phase 1). // Node only, no dependencies. It fails with a list of problems when: // // - a route of src/routes has no prerendered HTML page; // - a page lacks the Content-Security-Policy , or the policy is not // the one promised (default-src 'self', connect-src 'self' and the three // relays of drand, object-src // 'none', base-uri 'none', form-action 'none', scripts and styles from the // origin only), allows another origin, a scheme or 'unsafe-*', or comes // after anything the browser could fetch; // - an inline script is missing from script-src, or script-src holds a hash // of no inline script; // - style-src-attr does not list exactly the hashes of the inline style // attributes that the client bundle writes (SvelteKit's route announcer), // with 'unsafe-hashes' and nothing else; // - a page has an inline style, an event handler attribute or a URL to // another origin, or a stylesheet imports or references one; // - the official .dkc fixtures are not shipped byte for byte, or a secret of // the fixtures (.dkk files, plaintexts, identities, payload identities, // access material, CONTROL_CBOR, and the heads, salts, comments and paths // of format 3) is anywhere in the build; // - a page loads the Unicode tables of the paths of format 3 with its first // load, not on demand; // - the client bundle holds tlock-js, drand-client or Babel's helpers, or a // nested copy of a package other than the noble copy under // @noble/post-quantum (plan of phase 2, section 3 and decision 5), as // .svelte-kit/output/client-modules.json records it (vite.config.ts); // - the client bundle holds a test, or a module of src/lib/dkc/testing/, // whose helpers write what only a generator of test vectors may write // (format 2, another security area than 32 KiB); // - a page loads noble, @scure/base or age-encryption with the page instead // of on demand, or a page of ON_DEMAND cannot load its code on demand: the // opening on /inspect (plan of phase 2, section 9) and the writer on // /create (plan of phase 3, section 3); // - licenses.txt lacks the notice of a package in the client bundle, the // copyright lines kept in the header of a module of src/ derived from // another project, or the license of the site. // // It reports the JavaScript that each page loads, raw and gzip. import { createHash } from 'node:crypto'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gzipSync } from 'node:zlib'; const ROOT = fileURLToPath(new URL('..', import.meta.url)); // The site directory: build/, or the first argument. const BUILD = process.argv[2] === undefined ? join(ROOT, 'build') : resolve(process.argv[2]); const ROUTES = join(ROOT, 'src', 'routes'); const FIXTURES = join(ROOT, 'testdata', 'fixtures'); const problems = []; const fail = (msg) => problems.push(msg); const rel = (p) => relative(ROOT, p).split(sep).join('/'); const inBuild = (p) => relative(BUILD, p).split(sep).join('/'); const sha256 = (b) => createHash('sha256').update(b).digest('hex'); function walk(dir) { const out = []; for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) out.push(...walk(p)); else out.push(p); } return out.sort(); } if (!existsSync(BUILD)) { console.error(`${BUILD} does not exist: run "npm run build" first.`); process.exit(1); } const files = walk(BUILD); // --------------------------------------------------------------------------- // Every route is prerendered. const pages = walk(ROUTES) .filter((p) => basename(p) === '+page.svelte') .map((p) => relative(ROUTES, p).split(sep).slice(0, -1).join('/')); const htmlFiles = pages.map((route) => join(BUILD, route === '' ? 'index.html' : `${route}.html`)); for (const [i, f] of htmlFiles.entries()) { if (!existsSync(f)) fail(`route /${pages[i]} has no prerendered page ${rel(f)}`); } // --------------------------------------------------------------------------- // The Content-Security-Policy of each page. const unescapeHtml = (s) => s.replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); const REQUIRED = { 'default-src': ["'self'"], 'connect-src': ["'self'", 'https://api.drand.sh', 'https://api2.drand.sh', 'https://api3.drand.sh'], 'style-src': ["'self'"], 'img-src': ["'self'"], 'font-src': ["'self'"], 'manifest-src': ["'self'"], 'frame-src': ["'none'"], 'worker-src': ["'none'"], 'object-src': ["'none'"], 'base-uri': ["'none'"], 'form-action': ["'none'"], }; const HASH = /^'sha256-[A-Za-z0-9+/]{43}='$/; const b64sha256 = (text) => `'sha256-${createHash('sha256').update(text, 'utf8').digest('base64')}'`; // The inline style attributes that the client bundle writes into the DOM // (Svelte templates are HTML strings in the JavaScript): the only ones the // policy may allow, by hash, in style-src-attr. const bundleStyles = new Set(); for (const f of files.filter((p) => p.endsWith('.js'))) { for (const [, value] of readFileSync(f, 'utf8').matchAll(/\sstyle="([^"]*)"/g)) bundleStyles.add(b64sha256(value)); } if (bundleStyles.size !== 1) fail(`the client bundle writes ${bundleStyles.size} distinct inline style attributes, want 1 (the route announcer)`); function checkPage(file) { const name = rel(file); const html = readFileSync(file, 'utf8'); const metas = [...html.matchAll(//gi)]; if (metas.length !== 1) { fail(`${name}: ${metas.length} Content-Security-Policy elements, want 1`); return; } const meta = metas[0]; // Nothing that loads a resource may come before the policy. const head = html.slice(0, meta.index); if (/<(script|link|style|img|iframe|object|embed|base)\b/i.test(head)) fail(`${name}: an element that loads resources precedes the CSP `); const policy = new Map(); for (const part of unescapeHtml(meta[1]).split(';')) { const [directive, ...sources] = part.trim().split(/\s+/); if (!directive) continue; if (policy.has(directive)) fail(`${name}: CSP directive ${directive} appears twice`); policy.set(directive, sources); } for (const [directive, want] of Object.entries(REQUIRED)) { const got = policy.get(directive); if (got === undefined) fail(`${name}: CSP lacks ${directive}`); else if (got.join(' ') !== want.join(' ')) fail(`${name}: CSP ${directive} is "${got.join(' ')}", want "${want.join(' ')}"`); } const scriptSrc = policy.get('script-src') ?? []; if (scriptSrc[0] !== "'self'") fail(`${name}: CSP script-src must start with 'self'`); const hashes = scriptSrc.slice(1); for (const h of hashes) if (!HASH.test(h)) fail(`${name}: CSP script-src allows ${h}; only 'self' and SHA-256 hashes are allowed`); const styleAttr = policy.get('style-src-attr') ?? []; if (styleAttr[0] !== "'unsafe-hashes'") fail(`${name}: CSP style-src-attr must start with 'unsafe-hashes'`); const attrHashes = styleAttr.slice(1); for (const h of attrHashes) { if (!HASH.test(h)) fail(`${name}: CSP style-src-attr allows ${h}; only SHA-256 hashes are allowed`); else if (!bundleStyles.has(h)) fail(`${name}: style-src-attr hash ${h} matches no inline style of the bundle`); } for (const h of bundleStyles) if (!attrHashes.includes(h)) fail(`${name}: the bundle's inline style ${h} is not in style-src-attr`); const known = new Set([...Object.keys(REQUIRED), 'script-src', 'style-src-attr']); for (const d of policy.keys()) if (!known.has(d)) fail(`${name}: unexpected CSP directive ${d}`); // Every inline script is allowed by its hash, and every hash is used. const inline = [...html.matchAll(/]*)?>([\s\S]*?)<\/script>/gi)]; const used = new Set(); for (const [, attrs = '', body] of inline) { if (/\ssrc=/i.test(attrs)) { if (body.trim() !== '') fail(`${name}: a