SPB Git

Repository Public

static.ts 4.8 KB 118 lines typescript

1/**2 * Static web app (Next.js static export in apps/web/out, `trailingSlash: true`) + the CSP.3 *4 * script-src never contains 'unsafe-inline'. The export emits inline bootstrap scripts, so at5 * startup every `*.html` is scanned and the sha256 of each inline <script> is computed; an HTML6 * response carries the hashes of ITS OWN inline scripts (a single global list would grow with7 * the number of pages and overflow header limits). Non-HTML responses get the base policy.8 */9import { createHash } from 'node:crypto';10import fs from 'node:fs';11import path from 'node:path';12import type { FastifyInstance } from 'fastify';13import fastifyStatic from '@fastify/static';14import type { ServerConfig } from './config.js';1516const INLINE_SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;1718export function inlineScriptHashes(html: string): string[] {19  const out = new Set<string>();20  for (const m of html.matchAll(INLINE_SCRIPT)) {21    const attrs = m[1] ?? '';22    const body = m[2] ?? '';23    if (/\bsrc\s*=/.test(attrs) || body.length === 0) continue;24    // JSON / other data blocks are not executed and need no hash.25    const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();26    if (type && type !== 'module' && type !== 'text/javascript' && type !== 'application/javascript') continue;27    out.add(`'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`);28  }29  return [...out];30}3132export function buildCsp(config: ServerConfig, scriptHashes: readonly string[] = []): string {33  const o = new URL(config.publicOrigin);34  const ws = `${o.protocol === 'https:' ? 'wss' : 'ws'}://${o.host}`;35  return [36    "default-src 'self'",37    ["script-src 'self' 'wasm-unsafe-eval'", ...scriptHashes].join(' '),38    "style-src 'self' 'unsafe-inline'",39    "img-src 'self' data: blob:",40    "font-src 'self' data:",41    `connect-src 'self' ${ws}`,42    "worker-src 'self' blob:",43    // Same-origin blob: previews only. Nothing may frame this site (frame-ancestors).44    "frame-src 'self' blob:",45    "frame-ancestors 'none'",46    "base-uri 'self'",47    "form-action 'self'",48    "object-src 'none'",49  ].join('; ');50}5152function scanHtml(dir: string): Map<string, string[]> {53  const out = new Map<string, string[]>();54  const walk = (d: string): void => {55    for (const e of fs.readdirSync(d, { withFileTypes: true })) {56      const p = path.join(d, e.name);57      if (e.isDirectory()) walk(p);58      else if (e.isFile() && e.name.endsWith('.html')) out.set(p, inlineScriptHashes(fs.readFileSync(p, 'utf8')));59    }60  };61  walk(dir);62  return out;63}6465/**66 * Base CSP + Permissions-Policy on EVERY response. Must be registered before the rate limiter67 * and the auth guard, otherwise the responses they refuse (401/403/429) would go out without it.68 */69export function registerSecurityHeaders(app: FastifyInstance, config: ServerConfig): void {70  const baseCsp = buildCsp(config);71  app.addHook('onRequest', async (_req, reply) => {72    void reply.header('Content-Security-Policy', baseCsp);73    void reply.header('Permissions-Policy', 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=(), interest-cohort=()');74  });75}7677export async function registerStatic(app: FastifyInstance, config: ServerConfig): Promise<void> {78  const baseCsp = buildCsp(config);79  const webDir = fs.existsSync(config.webDir) ? fs.realpathSync(config.webDir) : null;80  const hashes = webDir ? scanHtml(webDir) : new Map<string, string[]>();81  const cspFor = (file: string): string => {82    const h = hashes.get(file);83    return h && h.length ? buildCsp(config, h) : baseCsp;84  };8586  if (webDir) {87    await app.register(fastifyStatic, {88      root: webDir,89      prefix: '/',90      index: 'index.html',91      redirect: true, // `/missions` → `/missions/` → missions/index.html92      cacheControl: false,93      dotfiles: 'deny',94      setHeaders(res, file) {95        const rel = path.relative(webDir, file).split(path.sep).join('/');96        if (rel.startsWith('_next/static/')) void res.header('Cache-Control', 'public, max-age=31536000, immutable');97        else if (file.endsWith('.html')) {98          void res.header('Cache-Control', 'no-cache');99          void res.header('Content-Security-Policy', cspFor(file));100        } else void res.header('Cache-Control', 'public, max-age=3600');101      },102    });103  }104105  const notFoundPage = webDir ? path.join(webDir, '404.html') : null;106  app.setNotFoundHandler((req, reply) => {107    if (req.url.startsWith('/api/') || !notFoundPage || !fs.existsSync(notFoundPage)) {108      return reply.code(404).send({ error: { code: 'not_found', message: 'not found' } });109    }110    return reply111      .code(404)112      .header('Content-Type', 'text/html; charset=utf-8')113      .header('Cache-Control', 'no-cache')114      .header('Content-Security-Policy', cspFor(notFoundPage))115      .send(fs.createReadStream(notFoundPage));116  });117}118
Files main
Menu

Theme