0044. Content Security Policy: report collector, enforcement, tool origins from the API
Generated from docs/decisions/0044-content-security-policy-enforcement.md
- Status: Proposed
- Date: 2026-10-09
- Plan:
docs/plans/leftovers-0-2.md(L1-05)
Context and problem statement
Section titled “Context and problem statement”The front and admin CSP is report-only and nothing collects the reports. frame-src is a static
list in Caddy, so registered LTI tools cannot be framed once the policy is enforced. cmi5 still plays
from the API origin; it moves to the content origin in L0-09.
Considered options
Section titled “Considered options”- A report collector, then enforcement. The front builds
frame-srcper request from the registered tool origins. The admin allowshttps:frames. - The same as 1, but the admin uses an exact list from runtime config.
- Keep report-only.
Decision
Section titled “Decision”Option 1:
- Collector. A rate-limited collector at
POST /api/csp-reportstores aggregated reports (host only, no query strings) for 30 days. - Front. The CSP header moves from Caddy to Astro middleware, so
frame-srccan include the tenant’s tool origins fromGET /api/lti/frame-origins(cached for 5 minutes). - Admin. The admin policy, still set by the proxy, allows
frame-src 'self' <content origins> https:. Admins are trusted staff and frame tools only for deep linking. - Enforcement.
CSP_ENFORCEswitches enforcement on; operators turn it on after 7 days with no unexpected reports. The dev stack enforces by default.
Implementation notes
Section titled “Implementation notes”- Collector.
POST /api/csp-reportstores one row per directive, blocked host and page path incsp_reports(count, first and last seen). It acceptsapplication/csp-reportandapplication/reports+json, takes at most 16 KB and 20 violations per request, is throttled to 60 requests a minute per IP, and is exempt from the Origin check (content origins and sandboxed frames report). Caddy opens exactly this path to any origin without credentials.csp-reports:pruneruns daily and deletes rows not seen forCSP_REPORT_RETENTION_DAYS(30). Admins readGET /api/admin/csp-reports. - Front.
front/web/src/lib/csp.tsbuilds the policy per request;frame-srcholds the page, the tenant’s content origin (ULAMS_CONTENT_ORIGIN), the upload origins, the oEmbed providers and the origins fromGET /api/lti/frame-origins(enabled tools; public; cached 5 minutes in the BFF and by clients). Every origin is checked to be a plainhttp(s)origin before it enters a source list. The proxied H5P pages keep the policy of the H5P service. - Admin and content origins. The Caddy
app_csp_adminsnippet and thecontent_originsnippet carryreport-uriandreport-to(withReporting-Endpoints) pointing to the tenant API. - Default.
CSP_ENFORCEunset means enforced outsideNODE_ENV=production(the dev stack), and report-only in the production image; the admin header name comes fromULAMS_CSP_HEADER.
Consequences
Section titled “Consequences”- Good: an enforced CSP on learner pages that still lets tool launches work.
- Bad: the admin frame policy is broader than the front’s (documented risk).
- Bad: the CSP is now set in two places, Astro middleware and the proxy.
- Default pending #52.