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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ red PR job against.
`'unsafe-hashes'` without handlers is rejected. The meta must precede every
`<script>`/`<link>`/`<img>`/`<style>` in `<head>`. Every external `<img>` origin must
be allowed by `img-src` (or `default-src` when `img-src` is absent), either explicitly
or via `https:`. `base-uri` must contain `'none'` or `'self'`, and `form-action` must
or via `https:`. Every external `<iframe>` origin must likewise be allowed by
`frame-src` (falling back to `child-src`, then `default-src`, as browsers do), and an
explicit `frame-src` may not list an origin no `<iframe>` on the page uses. `base-uri`
must contain `'none'` or `'self'`, and `form-action` must
be present. Each rule has a fixture self-test. Zero dependencies.
- `node --test scripts/update-csp-hashes.test.mjs` — fixture self-test of
`scripts/update-csp-hashes.mjs`, the tool that regenerates the `sha256-` allow-list the
Expand Down
23 changes: 23 additions & 0 deletions scripts/page-csp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ export function externalImageOrigins(html) {
return [...out].sort();
}

// Origins of every <iframe> whose src is absolute http(s); relative, srcdoc
// and about: frames are same-document and governed by no fetch directive.
export function externalFrameOrigins(html) {
const out = new Set();
for (const m of html.matchAll(/<iframe\b[^>]*>/gi)) {
const src = attrs(m[0]).src ?? "";
if (/^https?:\/\//i.test(src)) out.add(new URL(src).origin);
}
return [...out].sort();
}

// The first CSP <meta> in <head>: its full tag text, parsed directives (in
// source order), and where it and the first fetch-triggering tag sit in <head>.
export function parseCsp(html) {
Expand Down Expand Up @@ -118,6 +129,18 @@ export function problemsOf(html) {
for (const origin of externalImageOrigins(html)) {
if (!imgSrc.includes(origin) && !imgSrc.includes("https:")) problems.push(`img-src does not allow external <img> origin ${origin}`);
}
// Browsers resolve frame-src -> child-src -> default-src (CSP3). A frame
// whose origin is not allowed is silently replaced by an empty box.
const frameSrc = d["frame-src"] ?? d["child-src"] ?? d["default-src"] ?? [];
const frameOrigins = externalFrameOrigins(html);
for (const origin of frameOrigins) {
if (!frameSrc.includes(origin) && !frameSrc.includes("https:")) problems.push(`frame-src does not allow external <iframe> origin ${origin}`);
}
// An explicit frame-src is the page's third-party-frame allow-list: an
// origin no <iframe> points at widens it for nothing (like a stale hash).
for (const origin of d["frame-src"] ?? []) {
if (/^https?:\/\//i.test(origin) && !frameOrigins.includes(origin)) problems.push(`frame-src allows ${origin} but no <iframe> on the page uses it`);
}
if (!(d["base-uri"] ?? []).some((t) => t === "'none'" || t === "'self'")) problems.push("base-uri must be 'none' or 'self'");
if (!d["form-action"]) problems.push("form-action directive missing");
return problems;
Expand Down
49 changes: 44 additions & 5 deletions scripts/page-csp.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@
// test recomputes every hash from the committed HTML and fails on mismatch
// (scripts/update-csp-hashes.mjs rewrites the policy; the helpers live in
// scripts/page-csp.mjs).
// It also keeps the policy from regressing to 'unsafe-inline'/'unsafe-eval'
// and keeps every external <img> origin covered by img-src.
// It also keeps the policy from regressing to 'unsafe-inline'/'unsafe-eval',
// keeps every external <img> origin covered by img-src and every external
// <iframe> origin covered by frame-src (with no frame-src origin left unused).
// Zero dependencies. Usage: node --test scripts/page-csp.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { relative } from "node:path";
import { ROOT, htmlFiles, isRedirectPage } from "./html-pages.mjs";
import { REGEN_HINT, externalImageOrigins, inlineScriptSources, parseCsp, problemsOf, sha256Source } from "./page-csp.mjs";
import { REGEN_HINT, externalFrameOrigins, externalImageOrigins, inlineScriptSources, parseCsp, problemsOf, sha256Source } from "./page-csp.mjs";

// --- per-page gates ---------------------------------------------------------

Expand All @@ -32,9 +33,9 @@ for (const file of htmlFiles()) {

// --- fixture self-tests -----------------------------------------------------

function fixture({ csp, script = "console.log(1)", handler = "", img = "" } = {}) {
function fixture({ csp, script = "console.log(1)", handler = "", img = "", frame = "" } = {}) {
const meta = csp === null ? "" : `<meta http-equiv="Content-Security-Policy" content="${csp}">`;
return `<!doctype html><html><head><meta charset="utf-8">${meta}<title>x</title></head><body>${img}<p${handler ? ` onerror="${handler}"` : ""}></p><script type="application/ld+json">{"@context":"https://schema.org","@type":"Thing"}</script><script>${script}</script></body></html>`;
return `<!doctype html><html><head><meta charset="utf-8">${meta}<title>x</title></head><body>${img}${frame}<p${handler ? ` onerror="${handler}"` : ""}></p><script type="application/ld+json">{"@context":"https://schema.org","@type":"Thing"}</script><script>${script}</script></body></html>`;
}

const BASE = "default-src 'none'; img-src 'self'; base-uri 'none'; form-action 'none'";
Expand Down Expand Up @@ -186,6 +187,44 @@ test("fixture: external <img> origin must be in img-src", () => {
assert.deepEqual(problemsOf(fixture({ csp: `default-src 'none'; img-src 'self' https://github.com; base-uri 'none'; form-action 'none'; script-src ${h}`, img })), []);
});

const FRAME = '<iframe src="https://www.youtube-nocookie.com/embed/x" title="x"></iframe>';

test("fixture: <iframe> without src, relative src and srcdoc contribute no external origin", () => {
assert.deepEqual(externalFrameOrigins('<iframe title="a"></iframe><iframe src="/embed.html" title="b"></iframe><iframe srcdoc="<p>x</p>" title="c"></iframe><IFRAME SRC="HTTPS://Example.org/e?x=1" title="d"></IFRAME>'), ["https://example.org"]);
const h = sha256Source("console.log(1)");
assert.deepEqual(problemsOf(fixture({ csp: `${BASE}; script-src ${h}`, frame: '<iframe src="/embed.html" title="x"></iframe>' })), []);
});

test("fixture: external <iframe> origin must be in frame-src", () => {
const h = sha256Source("console.log(1)");
assert.deepEqual(problemsOf(fixture({ csp: `${BASE}; frame-src https://example.org; script-src ${h}`, frame: FRAME })), [
"frame-src does not allow external <iframe> origin https://www.youtube-nocookie.com",
"frame-src allows https://example.org but no <iframe> on the page uses it",
]);
assert.deepEqual(problemsOf(fixture({ csp: `${BASE}; frame-src https://www.youtube-nocookie.com; script-src ${h}`, frame: FRAME })), []);
assert.deepEqual(problemsOf(fixture({ csp: `${BASE}; frame-src https:; script-src ${h}`, frame: FRAME })), []);
});

test("fixture: without frame-src, child-src then default-src governs <iframe> origins", () => {
const h = sha256Source("console.log(1)");
assert.deepEqual(problemsOf(fixture({ csp: `${BASE}; child-src https://www.youtube-nocookie.com; script-src ${h}`, frame: FRAME })), []);
assert.deepEqual(problemsOf(fixture({ csp: `${BASE}; script-src ${h}`, frame: FRAME })), [
"frame-src does not allow external <iframe> origin https://www.youtube-nocookie.com",
]);
assert.deepEqual(problemsOf(fixture({ csp: `img-src 'self'; base-uri 'none'; form-action 'none'; script-src ${h}`, frame: FRAME })), [
"default-src must be 'none' so every fetch type is allow-listed explicitly",
"frame-src does not allow external <iframe> origin https://www.youtube-nocookie.com",
]);
});

test("fixture: a frame-src origin no <iframe> uses is reported; 'self' and 'none' are not origins", () => {
const h = sha256Source("console.log(1)");
assert.deepEqual(problemsOf(fixture({ csp: `${BASE}; frame-src 'self' https://www.youtube-nocookie.com; script-src ${h}` })), [
"frame-src allows https://www.youtube-nocookie.com but no <iframe> on the page uses it",
]);
assert.deepEqual(problemsOf(fixture({ csp: `${BASE}; frame-src 'none'; script-src ${h}` })), []);
});

test("fixture: policy placed after a <script> in <head> is reported", () => {
const h = sha256Source("console.log(1)");
const html = `<!doctype html><html><head><meta charset="utf-8"><script>console.log(1)</script><meta http-equiv="Content-Security-Policy" content="${BASE}; script-src ${h}"></head><body></body></html>`;
Expand Down
Loading