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