Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -36,6 +37,8 @@ export function createApp({
notifier?: Notifier;
}) {
const app = new Hono<AppEnv>();
const indexHtml = config.webDir ? readIndexHtml(config.webDir) : undefined;
app.use('*', securityHeaders(config, indexHtml));

if (config.logRequests) app.use(logger());

Expand Down Expand Up @@ -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;
}
Expand All @@ -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<AppEnv>, 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<AppEnv>, webDir: string, indexHtml: string) {
app.use(
'*',
serveStatic({
Expand Down
54 changes: 54 additions & 0 deletions apps/api/src/security-headers.spec.ts
Original file line number Diff line number Diff line change
@@ -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'),
`<html><head><script>${THEME}</script>` +
'<script type="module" crossorigin src="/assets/index.js"></script>' +
'</head><body></body></html>',
);
return dir;
}

describe('security headers', () => {
it('allows only the page’s own inline script', () => {
const html = `<script>${THEME}</script><script src="/a.js"></script>`;
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',
);
});
});
51 changes: 51 additions & 0 deletions apps/api/src/security-headers.ts
Original file line number Diff line number Diff line change
@@ -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 <style> tag for the logo picker.
styleSrc: ["'self'", "'unsafe-inline'"],
// blob: is the logo picker's preview of the chosen file.
imgSrc: ["'self'", 'data:', 'blob:', TILES],
// MapLibre's worker fetches the tiles.
connectSrc: ["'self'", TILES],
workerSrc: ["'self'", 'blob:'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
},
xFrameOptions: 'DENY',
// OpenStreetMap's tile servers require a Referer, so send the origin
// (never the path) to other sites.
referrerPolicy: 'strict-origin-when-cross-origin',
strictTransportSecurity: config.origin.startsWith('https:')
? 'max-age=31536000'
: false,
});
}

/** 'sha256-…' sources for each inline <script> in the page. */
export function inlineScriptHashes(html: string) {
return [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)]
.map((m) => m[1])
.filter((body) => body.trim())
.map(
(body) =>
`'sha256-${createHash('sha256').update(body).digest('base64')}'`,
);
}
5 changes: 4 additions & 1 deletion apps/web/src/components/maplibre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export { type GeoJSONSource, LngLatBounds, MapLibreMap, NavigationControl };

export const BRAND = '#0d9488';

/** OpenStreetMap raster tiles; no API key needed. */
/**
* OpenStreetMap raster tiles; no API key needed. Another tile host also needs
* adding to the Content-Security-Policy in apps/api/src/security-headers.ts.
*/
export const OSM_STYLE: StyleSpecification = {
version: 8,
sources: {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './zod-config';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom/client';
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/zod-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod';

// Zod's faster parsing compiles code with `new Function`, which the server's
// Content-Security-Policy blocks, and its check for that is reported as a
// violation on every page. Zod runs the check as each schema is created, so
// main.tsx imports this before anything else.
z.config({ jitless: true });
Loading