Security headers and the CSP
Every origin ulams serves sends a Content Security Policy (CSP). The policy of the learner front is built by the front itself, so it can allow the external tools (LTI) each tenant registered. The admin and the content origins get theirs from the reverse proxy.
| Origin | Policy | Set by |
|---|---|---|
| Learner front | default-src 'self'; scripts, styles, connections and base limited to the page and the tenant API; frame-src is the page, the tenant’s content origin, uploads, YouTube (no-cookie) and Vimeo embeds, plus the origins of the tenant’s enabled LTI tools |
front/web/src/middleware.ts |
| Admin | The same shape; frame-src 'self', the content origins and https: (staff frame tools for deep linking) |
Caddy app_csp_admin snippet |
| Content origins | Always enforced (strict, connect-src limited to the tenant API) |
Caddy content_origin snippet |
All three carry report-uri and report-to, so browsers send violations to the tenant API.
Reports
Section titled “Reports”POST /api/csp-report is public (no credentials), limited to 60 requests a minute per IP and 16 KB
per body. It accepts both wire formats (application/csp-report and application/reports+json) and
keeps one row per directive, blocked host and page path with a counter: no full URLs, no query
strings, nothing from the report text. Other content types answer 415, a body over 16 KB 413, a
body that is not JSON 400; a stored report answers 204. Rows not seen for 30 days
(CSP_REPORT_RETENTION_DAYS, default 30) are deleted every night at 03:20 by csp-reports:prune, which
runs from the scheduler. The Caddy csp_report block in the development Caddyfile and the production
example lets the content origins post to the endpoint without credentials.
Admins (role admin) read the rows at GET /api/admin/csp-reports with a bearer token (per_page
1 to 100); each row has directive, blocked_host, document_path, count, first_seen_at and
last_seen_at. There is no screen for it in the admin panel yet, so use the API or ulams csp-reports list
(see the CLI). A row such as
frame-src / tool.example.com / /learn/3/12 with a high count means a page tried to frame a
host the policy does not allow.
Tools in the front’s policy
Section titled “Tools in the front’s policy”The front asks the tenant API for the origins of its enabled LTI tools (GET /api/lti/frame-origins:
the host of each tool’s login, launch and deep-linking URL) and caches the answer for five minutes.
A tool you register or enable is therefore framed by learners within five minutes, without touching
any proxy file. Origins that are not plain http(s) hosts are dropped.
Report-only first, then enforce
Section titled “Report-only first, then enforce”| Setting | Where | Default |
|---|---|---|
CSP_ENFORCE |
web |
true in development, report-only when NODE_ENV=production (the Docker image) |
ULAMS_CSP_HEADER |
proxy (admin) | development: Content-Security-Policy; production example: Content-Security-Policy-Report-Only |
ULAMS_CONTENT_ORIGIN |
web |
http://{slug}.content.localhost; set it to your content origin (https://{slug}.content.<domain>) |
ULAMS_STORAGE_ORIGINS |
web |
http://storage.localhost; the origins that serve uploaded files |
- Deploy with the defaults. The production image reports violations without blocking anything.
- Use the site for about a week: learners (every topic type), the studio, an LTI launch, an H5P lesson, SCORM, LiaScript and cmi5.
- Read
GET /api/admin/csp-reports. Every row is either a source to allow (setULAMS_CONTENT_ORIGIN/ULAMS_STORAGE_ORIGINS, register the tool) or something that should not be loaded. - With no unexpected reports, set
CSP_ENFORCE=trueon the front andULAMS_CSP_HEADER=Content-Security-Policyon the proxy for the admin.