From 156f7a0a993ad720e1d490837d63af70b14b82db Mon Sep 17 00:00:00 2001 From: Andrew McCallum Date: Mon, 2 Nov 2026 20:59:32 +0000 Subject: [PATCH] Send security headers: CSP, no framing, HSTS on HTTPS Every response now carries a Content-Security-Policy that allows only openRun's own scripts (the inline theme script by hash) and OpenStreetMap tiles, forbids framing (clickjacking), and on HTTPS asks browsers never to use plain HTTP. The Referrer-Policy sends only the origin elsewhere, which OpenStreetMap's tile servers require. Zod's fast path probes eval, which the policy blocks, so the web app turns it off before any schema is created. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 16 ++++++-- apps/api/src/security-headers.spec.ts | 54 +++++++++++++++++++++++++++ apps/api/src/security-headers.ts | 51 +++++++++++++++++++++++++ apps/web/src/components/maplibre.ts | 5 ++- apps/web/src/main.tsx | 1 + apps/web/src/zod-config.ts | 7 ++++ 6 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/security-headers.spec.ts create mode 100644 apps/api/src/security-headers.ts create mode 100644 apps/web/src/zod-config.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dae1fb2..5a5137a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import type { Config } from './config.js'; import type { AppEnv } from './context.js'; import { demoLimits } from './demo/limits.js'; import { Notifier } from './push.js'; +import { securityHeaders } from './security-headers.js'; import { activityRoutes, personalBestRoutes } from './routes/activities.js'; import { clubRoutes } from './clubs/routes.js'; import { adminRoutes } from './routes/admin.js'; @@ -36,6 +37,8 @@ export function createApp({ notifier?: Notifier; }) { const app = new Hono(); + const indexHtml = config.webDir ? readIndexHtml(config.webDir) : undefined; + app.use('*', securityHeaders(config, indexHtml)); if (config.logRequests) app.use(logger()); @@ -69,7 +72,9 @@ export function createApp({ app.route('/api/integrations', integrationRoutes); app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404)); - if (config.webDir) serveWeb(app, config.webDir); + if (config.webDir && indexHtml !== undefined) { + serveWeb(app, config.webDir, indexHtml); + } return app; } @@ -94,13 +99,16 @@ function withClientIp(req: Request, c: Context | null) { } /** Serves the built PWA, falling back to index.html for client-side routes. */ -function serveWeb(app: Hono, webDir: string) { +function readIndexHtml(webDir: string) { const indexPath = join(webDir, 'index.html'); if (!existsSync(indexPath)) { console.warn(`WEB_DIR is set but ${indexPath} does not exist`); - return; + return undefined; } - const indexHtml = readFileSync(indexPath, 'utf8'); + return readFileSync(indexPath, 'utf8'); +} + +function serveWeb(app: Hono, webDir: string, indexHtml: string) { app.use( '*', serveStatic({ diff --git a/apps/api/src/security-headers.spec.ts b/apps/api/src/security-headers.spec.ts new file mode 100644 index 0000000..1ea8f40 --- /dev/null +++ b/apps/api/src/security-headers.spec.ts @@ -0,0 +1,54 @@ +import { createHash } from 'node:crypto'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { inlineScriptHashes } from './security-headers.js'; +import { setup } from './test-utils.js'; + +const THEME = "document.documentElement.dataset.theme = 'dark';"; +const sha = (s: string) => + `'sha256-${createHash('sha256').update(s).digest('base64')}'`; + +function webDir() { + const dir = mkdtempSync(join(tmpdir(), 'openrun-web-')); + writeFileSync( + join(dir, 'index.html'), + `` + + '' + + '', + ); + return dir; +} + +describe('security headers', () => { + it('allows only the page’s own inline script', () => { + const html = ``; + expect(inlineScriptHashes(html)).toEqual([sha(THEME)]); + }); + + it('sends a content security policy with every response', async () => { + const { app } = setup({ WEB_DIR: webDir() }); + for (const path of ['/', '/api/health']) { + const res = await app.request(path); + const csp = res.headers.get('content-security-policy') ?? ''; + expect(csp).toContain(`script-src 'self' ${sha(THEME)}`); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain('https://tile.openstreetmap.org'); + expect(res.headers.get('x-frame-options')).toBe('DENY'); + expect(res.headers.get('referrer-policy')).toBe( + 'strict-origin-when-cross-origin', + ); + // Plain HTTP here, so no HSTS. + expect(res.headers.get('strict-transport-security')).toBeNull(); + } + }); + + it('asks browsers to stay on HTTPS when served over it', async () => { + const { app } = setup({ ORIGIN: 'https://run.example.com' }); + const res = await app.request('/api/health'); + expect(res.headers.get('strict-transport-security')).toBe( + 'max-age=31536000', + ); + }); +}); diff --git a/apps/api/src/security-headers.ts b/apps/api/src/security-headers.ts new file mode 100644 index 0000000..5196a7e --- /dev/null +++ b/apps/api/src/security-headers.ts @@ -0,0 +1,51 @@ +import { createHash } from 'node:crypto'; +import { secureHeaders } from 'hono/secure-headers'; +import type { Config } from './config.js'; + +/** Map tiles; see OSM_STYLE in apps/web/src/components/maplibre.ts. */ +const TILES = 'https://tile.openstreetmap.org'; + +/** + * Headers that keep browsers from running anything openRun didn't send, from + * framing it on another site, and (on HTTPS) from ever using plain HTTP. + * + * `indexHtml` is the built web app's page: its inline script (the theme, set + * before the first paint) is allowed by hash, so no other inline script runs. + */ +export function securityHeaders(config: Config, indexHtml?: string) { + return secureHeaders({ + contentSecurityPolicy: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", ...inlineScriptHashes(indexHtml ?? '')], + // react-easy-crop adds a