Repository Public
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