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
18 changes: 18 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions api/_tests/csp.test.ts
Original file line number Diff line number Diff line change
@@ -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 <script> with no src attribute. */
const inlineScripts = Array.from(html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)).map(
match => match[1],
);

describe('Content-Security-Policy', () => {
it('lists the hash of every inline script in index.html', () => {
expect(inlineScripts.length).toBeGreaterThan(0);
const scriptSrc = directive('script-src');
for (const script of inlineScripts) {
const hash = `'sha256-${createHash('sha256').update(script).digest('base64')}'`;
expect(scriptSrc, `add ${hash} to script-src in vercel.json`).toContain(hash);
}
});

it('does not allow inline or evaluated script', () => {
const scriptSrc = directive('script-src');
expect(scriptSrc).not.toContain("'unsafe-inline'");
expect(scriptSrc).not.toContain("'unsafe-eval'");
expect(scriptSrc.some(source => source === '*' || source === 'https:' || source === 'data:')).toBe(false);
});

it('keeps fetches on this origin: every node is reached through a same-origin prefix', () => {
expect(directive('connect-src')).toEqual(["'self'"]);
});

it('forbids framing, plugins and a foreign base', () => {
expect(directive('frame-ancestors')).toEqual(["'none'"]);
expect(directive('object-src')).toEqual(["'none'"]);
expect(directive('base-uri')).toEqual(["'self'"]);
expect(header('X-Frame-Options')).toBe('DENY');
expect(header('X-Content-Type-Options')).toBe('nosniff');
});
});
358 changes: 181 additions & 177 deletions bun.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400..600&family=Geist:wght@400..700&display=swap" rel="stylesheet" />
<!-- Anti-FOUC: set theme before first paint. A stored choice wins, then
the OS preference. useTheme reads the result back, so the two agree.
The key must match KEY in src/hooks/useTheme.ts. -->
The key must match KEY in src/hooks/useTheme.ts.
This is the only inline script, and the Content-Security-Policy in
vercel.json allows it by its sha256 hash. Changing so much as its
whitespace changes the hash, and a script whose hash is not listed is
blocked without an error on the page. api/_tests/csp.test.ts fails
when the two disagree and prints the hash to put in vercel.json. -->
<script>
(function () {
var stored = localStorage.getItem('twigpine-theme');
Expand Down
Loading
Loading