diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9607733..412ad37 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,24 @@ npm run build All three must pass. `npm run build` includes the TypeScript project build (`tsc -b`), so type errors fail there. +### Editing the security headers + +`vercel.json`'s `headers` block is strict JSON — it can't carry inline +comments — so these two couplings are documented here instead: + +- **The CSP `script-src` hash and the inline theme script.** `index.html`'s + anti-FOUC theme script is allowlisted by sha256 hash rather than + `'unsafe-inline'`. Changing that script, even its whitespace, changes the + hash, and a stale hash in `vercel.json` makes the script silently stop + running under CSP (symptom: a theme flash on load, stored preference + ignored). Recompute the hash and update `script-src` whenever the script + changes. +- **The CSP `connect-src` allowlist and `FEDERATED_NODES`.** `src/lib/nodes.ts` + lists every node host the app talks to; `vercel.json`'s `connect-src` (and + its per-node proxy `rewrites`) must list the same hosts. There's no CSP in + dev, so a host added to one but not the other passes locally and is + silently blocked in production. + ## What makes a good PR - **Keep it focused.** One logical change per PR. Refactors, formatting sweeps, and diff --git a/api/_tests/csp.test.ts b/api/_tests/csp.test.ts new file mode 100644 index 0000000..8a7da82 --- /dev/null +++ b/api/_tests/csp.test.ts @@ -0,0 +1,62 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +/** + * The Content-Security-Policy in vercel.json allows the page's one inline + * script by its sha256 hash. The two live in different files, and a mismatch + * does not fail the build: the browser blocks the script and the page loads + * without it. These tests are the link between them. + */ +const root = new URL('../../', import.meta.url); +const html = readFileSync(new URL('index.html', root), 'utf8'); +const vercel = JSON.parse(readFileSync(new URL('vercel.json', root), 'utf8')) as { + headers: { source: string; headers: { key: string; value: string }[] }[]; +}; + +const everyRoute = vercel.headers.find(entry => entry.source === '/(.*)'); +const header = (name: string) => + everyRoute?.headers.find(h => h.key.toLowerCase() === name.toLowerCase())?.value ?? ''; + +const csp = header('Content-Security-Policy'); +const directive = (name: string) => + csp + .split(';') + .map(part => part.trim().split(/\s+/)) + .find(([key]) => key === name) + ?.slice(1) ?? []; + +/** Inline scripts: a ')).toBe(false); + expect(isHttpUrl('vbscript:msgbox(1)')).toBe(false); + expect(isHttpUrl('//evil.com')).toBe(false); + }); +}); + describe('shortRefName', () => { it('strips refs/heads/ to the branch name', () => { expect(shortRefName('refs/heads/main')).toBe('main'); diff --git a/src/lib/api.ts b/src/lib/api.ts index 9cc518c..ad70990 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -441,6 +441,21 @@ export function peerHost(url: string): string { } } +/** + * Whether a URL is safe to render as an anchor `href`. Peer/repo URLs + * (`http_url`, `clone_url`) come from the gossip network and other nodes, not + * this app, so a `javascript:` scheme must be rejected before it ever reaches + * the DOM — React does not filter link schemes the way DOMPurify does. + */ +export function isHttpUrl(url: string): boolean { + try { + const { protocol } = new URL(url); + return protocol === 'https:' || protocol === 'http:'; + } catch { + return false; + } +} + /** `refs/heads/main` → `main`; other refs lose only the `refs/` prefix. */ export function shortRefName(ref: string): string { if (ref.startsWith('refs/heads/')) return ref.slice('refs/heads/'.length); diff --git a/src/lib/nodes.ts b/src/lib/nodes.ts index a0778c3..c505301 100644 --- a/src/lib/nodes.ts +++ b/src/lib/nodes.ts @@ -1,6 +1,10 @@ // Federated node registry for the network page. The nodes serve no CORS // headers, so peer nodes are reached through per-node proxy prefixes — see // vite.config.ts (dev) and vercel.json (prod), which must list the same hosts. +// Because every node is reached through a same-origin prefix, the +// Content-Security-Policy in vercel.json allows `connect-src 'self'` only. A +// node fetched directly by its own host, instead of through a prefix, would +// work in dev (no policy there) and be blocked in production. import type { NodeInfo, NodeStats, ApiPeer, P2PInfo, ApiRefUpdate } from './api'; export interface FederatedNode { diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index dfa6812..11b0279 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -10,6 +10,7 @@ import { shortSha, parseEventRepo, didKeySegment, + isHttpUrl, } from '../lib/api'; import type { ApiRefUpdate } from '../lib/api'; import { CopyButton } from '../components/ui/CopyButton'; @@ -348,13 +349,13 @@ export default function HomePage() { quickstart - {cloneRepo && ( + {cloneRepo && isHttpUrl(cloneRepo.clone_url) && ( <> {' '}· or over{' '} https diff --git a/vercel.json b/vercel.json index 6654b85..b8863ca 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,20 @@ { + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'sha256-/kvpBYl+3xsGjx6GC5lquiRmnUh+MpJhYMrJ8XaHi1w='; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https: data:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }, + { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" } + ] + } + ], "redirects": [ { "source": "/",