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": "/",