SPB Git

Repository Public

static.ts 6.7 KB 154 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 PDF preview (blob: object URLs). 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}7677const BRAND_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;7879/**80 * Per-profile PWA assets. The web export is ONE build for both products; its default81 * `/manifest.webmanifest` and `/icons/*` are the Claude Remote ones. For another agent profile82 * the same URLs are answered from `<webDir>/brand/<agent>/…` when that file exists (otherwise the83 * default file is served). Returns the path relative to `webDir`, or null to use the default.84 */85export function brandAsset(webDir: string, agent: string, urlPath: string): string | null {86  if (agent === 'claude') return null;87  let rel: string;88  if (urlPath === '/manifest.webmanifest') rel = 'manifest.webmanifest';89  else if (urlPath.startsWith('/icons/') && BRAND_FILE.test(urlPath.slice('/icons/'.length))) rel = `icons/${urlPath.slice('/icons/'.length)}`;90  else return null;91  const candidate = path.join('brand', agent, rel);92  try {93    // A regular file that really lives inside the export (no symlink leading out of it).94    const real = fs.realpathSync(path.join(webDir, candidate));95    if (!real.startsWith(webDir + path.sep) || !fs.statSync(real).isFile()) return null;96  } catch {97    return null;98  }99  return candidate.split(path.sep).join('/');100}101102export async function registerStatic(app: FastifyInstance, config: ServerConfig): Promise<void> {103  const baseCsp = buildCsp(config);104  const webDir = fs.existsSync(config.webDir) ? fs.realpathSync(config.webDir) : null;105  const hashes = webDir ? scanHtml(webDir) : new Map<string, string[]>();106  const cspFor = (file: string): string => {107    const h = hashes.get(file);108    return h && h.length ? buildCsp(config, h) : baseCsp;109  };110111  if (webDir) {112    await app.register(fastifyStatic, {113      root: webDir,114      prefix: '/',115      index: 'index.html',116      redirect: true, // `/terminal` → `/terminal/` → terminal/index.html117      cacheControl: false,118      dotfiles: 'deny',119      setHeaders(res, file) {120        const rel = path.relative(webDir, file).split(path.sep).join('/');121        if (rel.startsWith('_next/static/')) void res.header('Cache-Control', 'public, max-age=31536000, immutable');122        else if (file.endsWith('.html')) {123          void res.header('Cache-Control', 'no-cache');124          void res.header('Content-Security-Policy', cspFor(file));125        } else void res.header('Cache-Control', 'public, max-age=3600');126      },127    });128  }129130  if (webDir && config.agent.id !== 'claude') {131    // More specific than the static wildcard, so these win; everything else is unchanged.132    const serve = (urlPath: (req: { params: unknown }) => string) => async (req: { params: unknown }, reply: { sendFile(file: string): unknown; header(k: string, v: string): unknown }) => {133      const p = urlPath(req);134      void reply.header('Cache-Control', 'public, max-age=3600');135      return reply.sendFile(brandAsset(webDir, config.agent.id, p) ?? p.slice(1));136    };137    app.get('/manifest.webmanifest', serve(() => '/manifest.webmanifest'));138    app.get('/icons/:file', serve((req) => `/icons/${String((req.params as { file?: unknown }).file ?? '')}`));139  }140141  const notFoundPage = webDir ? path.join(webDir, '404.html') : null;142  app.setNotFoundHandler((req, reply) => {143    if (req.url.startsWith('/api/') || !notFoundPage || !fs.existsSync(notFoundPage)) {144      return reply.code(404).send({ error: { code: 'not_found', message: 'not found' } });145    }146    return reply147      .code(404)148      .header('Content-Type', 'text/html; charset=utf-8')149      .header('Cache-Control', 'no-cache')150      .header('Content-Security-Policy', cspFor(notFoundPage))151      .send(fs.createReadStream(notFoundPage));152  });153}154
Files main
Menu

Theme