Files
kevinveenbirkenbachandClaude Opus 5 43ec22ee20 fix(e2e): read the CSP off the response, not a second fetch
readCspString re-fetched the current URL through the Playwright API
request context and, when that failed, waited actionTimeout (30s) for a
<meta http-equiv="Content-Security-Policy"> that no template in this
repo emits. On a healthy runner that path works and costs one extra
request; when name resolution degrades it collapses, because the API
request context resolves DNS separately from the browser: the re-fetch
burns its 10s cap and the meta wait then drains whatever is left of the
caller's test budget.

That is what killed web-svc-logout's guest persona in run 32597248629,
job Pretix#0, whose swarm manager had a single resolver on 127.0.0.1
with dnsmasq no longer answering. Every guest test on that host inflated
to ~40s and the logout one, loading two external assets on top, died on
its 60s timeout having asserted nothing. The same role in Bluesky#0 and
YOURLS#0 of the same run, same role set, finished in 0.85s and 0.82s -
so this is a resilience fix, not a repair of a broken assertion.

gotoOnion now installs a page.on("response") recorder, so the header is
taken from the document the browser already received. That also covers
the surface reached through an SSO round trip, where no navigation
Response is in scope at the assertion - only guest.js holds one, biber,
admin and the suitecrm saml spec discard theirs. The re-fetch stays as
the fallback for a spec that navigates without gotoOnion; the meta
fallback is deleted.

The suite pins the header precedence (enforced over report-only) and
every guard in assertCspInjections: a disabled injector, a gate that
throws, an enabled injector without a base URL, a missing gate, a second
enabled injector, and the case-insensitive host match. Inverting the
precedence or dropping toLowerCase each trips exactly one test.

Verified against a headless Chromium and a local server counting
requests: the header survives a 302, no extra request is issued, a
missing injector host still fails the assertion, and a header-less page
falls back to exactly one re-fetch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 04:31:55 +02:00

347 lines
12 KiB
JavaScript

/**
* CSP injection assertion shared by every persona scenario.
*
* `assertCspInjections` reads the raw `content-security-policy` (or
* `content-security-policy-report-only`) header of the document the
* browser is currently showing, then verifies that every service whose
* JavaScript / CSS / asset is actually injected on the page is also
* covered by the role's CSP.
*
* The intent is symmetric: when an injector role like `mastodon`,
* `web-svc-asset`, `web-svc-cdn`, `web-svc-css`, `web-svc-javascript`
* or `web-svc-simpleicons` is enabled, its origin MUST appear in the
* CSP. When the injector is disabled, its origin MUST NOT appear in
* any rendered `<script>` / `<link>` / `<img>` tag — otherwise the
* page would load resources the CSP doesn't permit.
*
* The header is captured as it arrives, by a recorder `gotoOnion`
* installs on the page, so the surface reached through an SSO round
* trip is covered like the first navigation. `page.request.get`
* remains as a fallback for a spec that navigates without `gotoOnion`.
*/
const { expect } = require("@playwright/test");
const { resolveTimeout } = require("../../timeouts");
// Injector base-URL resolution. Switch-case form (rather than a
// dictionary lookup) is intentional: it gives `tests/lint/ansible/
// roles/web-app/playwright/test_env_keys_used.py` a literal
// `process.env.<KEY>` reference for each consumer, so the env-keys-
// used parity guard recognises these env vars as consumed by the
// shared helper.
function injectorBaseUrl(service) {
switch (service) {
case "asset":
return process.env.ASSET_BASE_URL || "";
case "cdn":
return process.env.CDN_BASE_URL || "";
case "css":
return process.env.CSS_BASE_URL || "";
case "javascript":
return process.env.JAVASCRIPT_BASE_URL || "";
case "simpleicons":
return process.env.SIMPLEICONS_BASE_URL || "";
case "matomo":
return process.env.MATOMO_BASE_URL || "";
default:
return "";
}
}
const INJECTOR_SERVICES = ["asset", "cdn", "css", "javascript", "simpleicons", "matomo"];
function hostOf(url) {
if (!url) return "";
try {
return new URL(url).hostname;
} catch {
return "";
}
}
const _RECORDED_CSP = new WeakMap();
function cspOfHeaders(headers) {
return (
headers["content-security-policy"] ||
headers["content-security-policy-report-only"] ||
""
);
}
/** Start recording the CSP header of `page`'s main-frame documents.
*
* Idempotent: repeated calls for the same page keep the first recorder.
* `gotoOnion` calls this before every navigation, so every persona flow
* has it regardless of which spec drives it.
*
* @param {object} page - the Playwright page to observe
*/
function installCspHeaderRecorder(page) {
if (_RECORDED_CSP.has(page)) return;
const state = { csp: "" };
_RECORDED_CSP.set(page, state);
page.on("response", (response) => {
try {
if (response.frame() !== page.mainFrame()) return;
if (response.request().resourceType() !== "document") return;
state.csp = cspOfHeaders(response.headers());
} catch {
/* page torn down mid-flight */
}
});
}
async function readCspString(page) {
const recorded = _RECORDED_CSP.get(page);
if (recorded && recorded.csp) return recorded.csp;
const currentUrl = page.url();
if (!currentUrl || currentUrl === "about:blank") return "";
const fresh = await page.request
.get(currentUrl, { ignoreHTTPSErrors: true, timeout: resolveTimeout(10_000) })
.catch(() => null);
return fresh ? cspOfHeaders(fresh.headers()) : "";
}
async function assertCspInjections(page, opts = {}) {
const { isEnabled } = opts;
if (typeof isEnabled !== "function") return;
const csp = await readCspString(page);
for (const service of INJECTOR_SERVICES) {
let enabled;
try {
enabled = isEnabled(service);
} catch {
enabled = false;
}
if (!enabled) continue;
const host = hostOf(injectorBaseUrl(service));
if (!host) continue;
if (!csp) continue;
expect(
csp.toLowerCase().includes(host.toLowerCase()),
`CSP for the current page MUST include ${host} (service '${service}' is enabled). Got: ${csp}`,
).toBe(true);
}
}
// -----------------------------------------------------------------------------
// CSP header / meta / violation assertions
//
// Shared by every spec that probes a role's `Content-Security-Policy`
// response header, its optional `<meta http-equiv="Content-Security-Policy">`
// echo, and the runtime `securitypolicyviolation` event stream.
// -----------------------------------------------------------------------------
const EXPECTED_CSP_DIRECTIVES = [
"default-src",
"connect-src",
"frame-ancestors",
"frame-src",
"script-src",
"script-src-elem",
"script-src-attr",
"style-src",
"style-src-elem",
"style-src-attr",
"font-src",
"worker-src",
"manifest-src",
"media-src",
"img-src",
];
/** Install a `securitypolicyviolation` listener that captures every
* blocked load on the page. Pair with `readCspViolations(page)` and
* `expectNoCspViolations(page, diagnostics, label)`. */
function installCspViolationObserver(page) {
return page.addInitScript(() => {
window.__cspViolations = [];
window.addEventListener("securitypolicyviolation", (event) => {
window.__cspViolations.push({
violatedDirective: event.violatedDirective,
blockedURI: event.blockedURI,
sourceFile: event.sourceFile,
lineNumber: event.lineNumber,
originalPolicy: event.originalPolicy,
});
});
});
}
async function readCspViolations(page) {
return page.evaluate(() => window.__cspViolations || []).catch(() => []);
}
/** Parse a `Content-Security-Policy` header string into a directive
* map (`{ "default-src": ["'self'", "https://example.com"], ... }`). */
function parseCspHeader(value) {
const result = {};
if (!value) return result;
for (const raw of value.split(";")) {
const trimmed = raw.trim();
if (!trimmed) continue;
const parts = trimmed.split(/\s+/);
const directive = parts.shift();
if (!directive) continue;
result[directive.toLowerCase()] = parts;
}
return result;
}
/** Assert the response carries an enforced (NOT report-only) CSP
* header that lists every directive in `EXPECTED_CSP_DIRECTIVES`.
* Returns the parsed header so callers can hand it to
* `assertCspMetaParity`. */
function assertCspResponseHeader(response, label) {
const headers = response.headers();
const cspHeader = headers["content-security-policy"];
expect(
cspHeader,
`${label}: Content-Security-Policy response header MUST be present`
).toBeTruthy();
const reportOnly = headers["content-security-policy-report-only"];
expect(
reportOnly,
`${label}: Content-Security-Policy-Report-Only MUST NOT be set (policy must be enforced)`
).toBeFalsy();
const parsed = parseCspHeader(cspHeader);
const missing = EXPECTED_CSP_DIRECTIVES.filter((directive) => !parsed[directive]);
expect(
missing,
`${label}: CSP directives missing from response header: ${missing.join(", ")}`
).toEqual([]);
return parsed;
}
/** When the role echoes its CSP via `<meta http-equiv="...">`, every
* token in the meta MUST also appear in the response header — meta
* MAY be a strict subset, never a strict superset. No-op when the
* meta tag is absent. */
async function assertCspMetaParity(page, headerDirectives, label) {
const metaLocator = page.locator('meta[http-equiv="Content-Security-Policy"]').first();
const hasMeta = (await metaLocator.count().catch(() => 0)) > 0;
if (!hasMeta) return;
const metaContent = await metaLocator.getAttribute("content").catch(() => null);
if (!metaContent) return;
const metaParsed = parseCspHeader(metaContent);
for (const directive of Object.keys(metaParsed)) {
const headerTokens = new Set(headerDirectives[directive] || []);
const metaTokens = metaParsed[directive] || [];
for (const token of metaTokens) {
expect(
headerTokens.has(token),
`${label}: CSP meta token "${token}" for directive ${directive} MUST also appear in the response header`
).toBe(true);
}
}
}
/** Assert no `securitypolicyviolation` events fired AND (when the
* caller passes a diagnostics object captured by `attachDiagnostics`)
* no CSP-related console / pageerror entries surfaced.
*
* `diagnostics` is optional: pass `null` (or omit the third arg) when
* the spec does not track diagnostics. The shared helper still
* validates the DOM-level violations either way. */
async function expectNoCspViolations(page, diagnostics, label) {
const domViolations = await readCspViolations(page);
expect(
domViolations,
`${label}: securitypolicyviolation events observed: ${JSON.stringify(domViolations)}`
).toEqual([]);
if (diagnostics && Array.isArray(diagnostics.cspRelated)) {
expect(
diagnostics.cspRelated,
`${label}: CSP-related console/pageerror entries observed: ${JSON.stringify(diagnostics.cspRelated)}`
).toEqual([]);
}
}
function captureAssetLoads(page, { hostCandidates, resourceTypes }) {
const hosts = (hostCandidates || []).map((h) => String(h || "").toLowerCase());
const types = resourceTypes && resourceTypes.length > 0 ? resourceTypes : null;
const loads = [];
page.on("response", (response) => {
try {
const host = new URL(response.url()).host.toLowerCase();
if (!hosts.includes(host)) return;
if (types && !types.includes(response.request().resourceType())) return;
loads.push({
url: response.url(),
status: response.status(),
type: response.request().resourceType(),
});
} catch {
// ignore unparseable URLs
}
});
return loads;
}
async function assertInjectedAssetLoadsWithoutCspBlock(page, {
url,
hostCandidates,
resourceTypes,
label,
navTimeout = 30_000,
waitUntil = "networkidle",
}) {
const hosts = (hostCandidates || []).map((h) => String(h || "").toLowerCase());
const types = resourceTypes && resourceTypes.length > 0 ? resourceTypes : null;
const loads = captureAssetLoads(page, { hostCandidates: hosts, resourceTypes: types });
await installCspViolationObserver(page);
// realtime apps never reach networkidle; captured asset loads still suffice
await page.goto(url, { waitUntil, timeout: navTimeout }).catch(() => {});
const successful = loads.filter((r) => r.status >= 200 && r.status < 400);
expect(
successful.length,
`${label}: browser observed no successful (2xx/3xx) response from ` +
`[${hosts.join(", ")}] for resourceType(s) [${(types || ["*"]).join(", ")}] ` +
`during navigation to ${url}. Observed loads: ${JSON.stringify(loads)}. ` +
`This means either the role's vhost does not inject the expected ` +
`<link>/<script> markup, the asset returned an error, or CSP blocked the request.`
).toBeGreaterThan(0);
const violations = await readCspViolations(page);
const blocks = violations.filter((v) => {
let blockedHost;
try {
blockedHost = new URL(v.blockedURI).host.toLowerCase();
} catch {
return false;
}
return hosts.includes(blockedHost);
});
expect(
blocks,
`${label}: CSP blocked load from injected asset host(s) ` +
`[${hosts.join(", ")}] during navigation to ${url}: ${JSON.stringify(blocks)}. ` +
`Add the host to the relevant *-src directive in the role's CSP.`
).toEqual([]);
}
module.exports = {
assertCspInjections,
EXPECTED_CSP_DIRECTIVES,
installCspHeaderRecorder,
installCspViolationObserver,
readCspViolations,
parseCspHeader,
assertCspResponseHeader,
assertCspMetaParity,
expectNoCspViolations,
captureAssetLoads,
assertInjectedAssetLoadsWithoutCspBlock,
};