#!/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;
// - a page loads the locator of datekeys.capsule (locator.ts, envelope.ts,
// ageio.ts, ipaddr.ts) 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(/