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:

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):

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

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.