Skip to content

M6-03 Content Security Policy with a headless test

No Date Ruling Reason
D-M6-03-0 2026-10-20 Base int/m1-wave46 at 9c05469, branch feat/M6-03-console-csp in its own worktree, with feat/M1-73-compose-smoke at 70589fb merged first (stacked build); not merged locally and not pushed except the build branch, the PR lane integrates it. The contract rule R-AZ-33 (action console.csp_report), which main holds and this base lacks, enters first as its own commit. Names that differ from the brief and are followed: the chapters are 19-sicherheit.md, 05-aktionen.md, 02-architektur.md, 06-api.md; the brief index of this base has no table "Make targets created by tickets" (main already lists console-e2e there); there is no ci_ok.py, the rule mechanism of M4-01 is the if of the job plus the success or skipped case in ci-ok. docs/api/openapi.yaml had 18 bytes left below repo.max_file_kb: the 403 generic example summaries summary: Request and summary: Response go in their own commit, the same content-neutral change as D-M3-03-21 of the next wave. The task names base, stack and lane; merged names win (D-W-100); the gate must not be loosened.
D-M6-03-1 2026-10-20 Policy builder (httpserver.ContentSecurityPolicy, csp.go): the directives of 19 5 in one function, the nonce of the response in style-src, the Trusted Types names from the one list TrustedTypesPolicies, report-to csp; no frame-src, no 'unsafe-inline', no 'unsafe-eval'. It applies to every response of the console mount below /console/ (index.html, assets, the SPA fallback, the redirect of /console and the 404 of a path that leaves the build), never to /api/, together with COOP same-origin, COEP require-corp, CORP same-origin, X-Frame-Options: DENY, the Permissions-Policy and Reporting-Endpoints: csp="/api/v1/csp-reports". Brief note 1; SEC-HDR-3.
D-M6-03-2 2026-10-20 Nonce: the server reads index.html once at the start and refuses a build without the placeholder __BOTHY_CSP_NONCE__ (the start ends); each response gets 128 bits from crypto/rand in base64 in the placeholder and in style-src, no-store and no ETag. The placeholder sits only in the nonce attribute of the bootstrap script tag; flutter_bootstrap.js hands document.currentScript.nonce to the loader option nonce, so one substitution serves both (an inline loader option would need a script nonce, which 19 5 does not allow). index.html has no <base> element, because base-uri 'none' blocks it (observed in Chromium); the build writes /console/ into the absolute URLs of the script and the icon ($FLUTTER_BASE_HREF), the loader derives entrypointBaseUrl, assetBase, canvasKitBaseUrl and fontFallbackBaseUrl from the URL of its script, and the app's path URL strategy takes /console/ from ConsolePlatformLocation. The fallback fonts stay the ones the build bundles (assets/fonts/fallback/); the console's own texts need no other font (no fallback request in any browser), a glyph outside them asks the own origin and gets 404, never a foreign origin. Brief note 2; 19 5 rules 1 and 2.
D-M6-03-3 2026-10-20 Trusted Types: the observed policy names are exactly flutter-js (the loader's policy for the script URLs it injects) in Chromium 156, Firefox 157 and WebKit 27.2 with the CanvasKit build; the engine creates no policy, there was no trusted-types violation. TrustedTypesPolicies and 19 5 rule 4 say so. Brief note 3; the list follows the test.
D-M6-03-4 2026-10-20 WebSocket: connect-src 'self' covers wss:// to the own origin in all three browsers; the test opens /api/v1/gateway from the page, sees no connect-src violation and the handshake reaching the server. No wss://<host> is added. Brief note 3; 19 5 rule 5.
D-M6-03-5 2026-10-20 Reports: submitCspReport (POST /api/v1/csp-reports, console.csp_report, rl.public_auth, x-idempotency-exempt: telemetry) takes application/reports+json and application/csp-report as the new body kind report of the route table and the chain (the only operation with two request media types): JSON guard and http.max_json_body_bytes, no schema check beyond that, the handler reads the raw bytes. It reads only the effective directive (legacy: effective-directive, else the first word of violated-directive), the disposition and the blocked URL, reduces them to fixed lists (directives of CSP Level 3, enforce, report, else other) and to the origin (http, https, ws, wss) or a keyword (inline, eval, wasm-eval, trusted-types-policy, trusted-types-sink, data, blob, else other), counts every violation in bothy.console.csp_violations, logs one Info record csp violation reported per distinct violation (identical ones share it) with bothy.csp.directive, bothy.csp.disposition and bothy.csp.blocked_origin, at most console.csp_report_max_records (default 20) per request, and stores nothing; without that bound one body of http.max_json_body_bytes produced about 30000 records (review finding). Reports of other types in the Reporting API array are skipped. The console guard of M6-01 does not check this action: a browser sends a report with the cookie of the same origin but never with X-Console-Request. Brief note 4; R-AZ-33.
D-M6-03-6 2026-10-20 Headless test (tools/console-e2e, Python with Playwright 1.64.0 from tools/uv.lock): the browsers run in the Playwright image of that version pinned by digest, plus the locked packages, in the network namespace of api, locally as in CI, because WebKit does not start headless on the macOS build host. Chromium runs as the full browser in its new headless mode (the headless shell never delivers Reporting API reports) with --short-reporting-delay and trusts the leaf by its SPKI hash; Firefox and WebKit accept the test certificate by the context option. The console has no passkey page yet (M6-04), so Chromium registers the passkey through beginPasskeyRegistration and completePasskeyRegistration from the page of the console session with the virtual authenticator (user verification on), logs out, logs in again and does the step-up with that passkey through the console. The negative test answers the navigation to /console/csp-negative with the headers and body the server just sent for that path plus one inline script (Playwright's route instead of a rewriting sidecar; the server image and its headers stay untouched) and requires the block, the violation event and the report in the server log. Text is typed key by key after the editing element has the focus, because Flutter loses a key typed before. Violations and Trusted Types names go from the init script through bindings of the context into Python, so the check covers every document of the session (the login and the navigation as well as the second login); a control after the negative test proves that a violation survives a navigation (review finding). Brief note 5; a supported browser of 15 2 must work.
D-M6-03-7 2026-10-20 Topology and CI: the overlay infra/compose/test/compose.console-e2e.yaml adds the TLS sidecar tls (Caddy 2.11.7 by digest, network_mode: service:api, port 8443, a test CA and leaf for localhost, api and 127.0.0.1 generated per run), the seed console-seed (bothy-smoke seed: the owner's device redeems the enrollment code, sets a password and confirms TOTP) and console-browser; server.public_url is https://localhost:8443, auth.webauthn_rp_id localhost, auth.webauthn_origins that origin, server.trusted_proxies stays loopback. No port is published on the host, because the browsers share the namespace. make console-e2e (not in gates) runs tools/console-e2e/console_e2e.py; the CI job console-e2e runs make image for the host platform and make console-e2e when apps or server changed, uploads the result files only on a failure and feeds ci-ok (success or skipped). The image builds the console and the server with the Flutter and Go pins of its Dockerfile, so the job installs neither. Brief notes 6 and 7; M1-73 D1.
D-M6-03-8 2026-10-22 Port onto main: the ticket range (7ad10a6..d1fe633) as one commit on current main, where M1-73, M6-02 and R-AZ-33 already landed. The spec additions of the German chapters go into the English chapters 06, 16 (the row of console.csp_report_max_records with the place "Server") and 19; R-AZ-33 and the catalogue entry of console.csp_report are already on main and stay as they are. The changelog entries and these rulings are fragments. Generated code (REST layer, clients, route and permission catalogues, tables of 06) is regenerated on main, not merged. The removal of the generic example summaries of docs/api/openapi.yaml (D-M6-03-0) is left out: main holds the contract to api.contract_max_file_kb (2048 KB, PL/9), so the space is not needed. The CI job console-e2e is a conditional job of the plan (tools/ci/components.py CONSOLE_E2E_JOBS) in ci.yml and build-verify.yml: it runs when the build context of the server image or the Compose stack of the test changes, for the component server or apps without a diff, and for every shared input, tools/console-e2e/ among them; ci_ok.py accepts a skip only when the plan left it out. The ticket's own change moves; main's English spec and its additions win.
D-M6-03-9 2026-10-22 Found by the contract tests (G7) on main, which the original base did not run for this operation: Schemathesis could not serialize application/csp-report, and the server accepted bodies the contract excludes (null, an item of the Reporting API array that is no object, a legacy body with a member besides csp-report). tools/contract/live_contract.py serializes application/csp-report as JSON, and domain.ParseCSPReports now rejects every body that does not have the contract's shape (CspReportList: an array of objects; CspReportLegacy: an object with exactly the object csp-report) with 400; members inside a report that the server does not read stay ignored. The contract is the source; a browser sends exactly these shapes, so no real report is refused.