Skip to content

Content origin

Uploaded packages (SCORM, cmi5, Adapt builds, LiaScript, Interactive) run arbitrary third-party JavaScript. ulams serves them from a content origin: a host per tenant that holds nothing but package files. It has no API routes, accepts and sets no cookies, and sends its own Content Security Policy. The full design is in api/docs/content-origin.md; this page covers what an operator sets up.

Mode Example (app on *.lms.example.com) Status
Separate registrable domain {slug}.example-content.net Strongest. Package code is cross-site with the app, so browsers keep their cookies and process isolation away from it.
Same-site subdomain {slug}.content.lms.example.com (for ulams.app: *.content.ulams.app) Supported with the mitigations below. Chosen for the hosted ulams.app service (product owner, 2026-10-09).

On a same-site content origin, package code is same-site with the application. Browsers then send SameSite=Lax and Strict cookies on its requests to the app’s hosts (so SameSite gives no CSRF protection against it), it can set cookies for the parent domain (cookie tossing, session fixation), and it shares site-level process isolation. ulams ships these mitigations, and they are also on in the separate-domain mode:

  1. Host-only cookies. Every session cookie uses the __Host- prefix in production (Secure, Path=/, never a Domain), so a sibling subdomain cannot overwrite them: the learner front and studio (__Host-ulams_session, __Host-ulams_author), and the API’s Laravel session and XSRF-TOKEN. Over plain http in development the prefix is dropped (ULAMS_COOKIE_FALLBACK_PREFIX, SESSION_COOKIE_PREFIX). Sessions created before the rollout used unprefixed names; ask users to sign in again or clear those cookies.
  2. Exact-Origin checks. Every POST, PUT, PATCH and DELETE to the learner front and to the API must come from the tenant’s own app origins (FRONTEND_URL, ADMIN_URL, APP_URL, plus TRUSTED_ORIGINS). A content origin, Origin: null or a Sec-Fetch-Site other than same-origin gets 403. Only routes authenticated by a non-ambient credential are exempt: the tracking-token endpoints, LTI, payment callbacks. The API authenticates with bearer tokens only (no cookie-only endpoint exists). CORS allow-lists never include content origins.
  3. Sandboxed frames. Players are framed with sandbox and referrerpolicy="no-referrer". SCORM, cmi5, Adapt, LiaScript and H5P still need allow-same-origin (the SCO finds window.API through its parent frame; LiaScript uses a Worker, storage and a service worker; H5P calls its service with fetch), so for them mitigations 1, 2 and 4 carry the protection.
  4. Response headers. Content files send the CSP, X-Content-Type-Options: nosniff, Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Resource-Policy: cross-origin. The app hosts send Cross-Origin-Resource-Policy: same-origin on sensitive JSON (API JSON, /bff/*, /studio/api/*), so content pages cannot embed it.
Where Setting Example
api TENANCY_CONTENT_HOST (tenants; written as CONTENT_ORIGIN into each env file) {slug}.example-content.net
api CONTENT_ORIGIN (platform) https://platform.example-content.net
api TRUSTED_ORIGINS (optional extra app origins allowed to write to the API) https://custom-front.example.com
DNS Wildcard on the content domain *.example-content.net, platform.example-content.net
Same-site mode TENANCY_CONTENT_HOST and ULAMS_CONTENT_DOMAIN on a subdomain of the app domain {slug}.content.lms.example.com, content.lms.example.com
Caddy One site per content host, using the content_origin snippet see the example Caddyfile

Existing tenants pick up a changed TENANCY_CONTENT_HOST on the next php artisan ulams:tenant:sync-env (it runs on every api start). A tenant without CONTENT_ORIGIN falls back to the legacy SCORM player on the API origin.

The strongest mode needs a second registrable domain. The domain below is a placeholder (example-content.net) until the real one is chosen; use yours everywhere it appears.

  1. Register the domain. Do not use a subdomain of the application’s registrable domain: that is the same-site mode.

  2. Create DNS records pointing at the server (A and AAAA, or a CNAME to your load balancer):

    Record Purpose
    *.example-content.net One content origin per tenant (<slug>.example-content.net)
    platform.example-content.net The platform’s own content origin
    storage.example-content.net Browser-facing object storage, only with the bundled MinIO

    A wildcard record does not cover the bare domain; it is not used.

  3. Set ULAMS_CONTENT_DOMAIN=example-content.net in the .env of the production example. The compose file turns it into TENANCY_CONTENT_HOST={slug}.example-content.net and CONTENT_ORIGIN=https://platform.example-content.net on the API, and the Caddyfile into the *.example-content.net, platform. and storage. sites.

  4. Certificates: with the default on-demand TLS, Caddy issues one for each tenant content host the first time it is requested, after the tenant check. With wildcard certificates (DNS-01) add *.example-content.net to the wildcard list. See DNS and TLS.

  5. Apply it to existing tenants and check the result:

    Terminal window
    docker compose up -d
    docker compose exec api php artisan ulams:upgrade
    curl -sI https://<slug>.example-content.net/scorm/x | head -5

    ulams:tenant:sync-env (a step of ulams:upgrade, and run at start) rewrites each tenant’s CONTENT_ORIGIN. The curl should answer from Caddy with the content headers and 404 (no such package), not 5xx and not a certificate error.

H5P is not served from the content origin; see H5P in production.

The content_origin snippet, copied from the development Caddyfile with HTTPS hosts:

  • answers only GET and HEAD for /scorm/*, /cmi5/*, /adapt/*, /liascript/* and /interactive/*, and 404 for everything else;
  • rewrites them to GET /api/content/... on the tenant API (php-fpm) with Host/X-Forwarded-Host set to the tenant API host and X-Ulams-Content-Origin: 1;
  • drops Cookie and Authorization on the way in and Set-Cookie on the way out;
  • sends Content-Security-Policy only when the API did not (header ?Content-Security-Policy; the API sets the policy of every /interactive/* file itself, from the package version’s manifest, see Interactive), and otherwise (scripts allowed, connect-src limited to the tenant API, frame-ancestors limited to the tenant front and admin, form-action 'none'), X-Content-Type-Options: nosniff, Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: cross-origin and Referrer-Policy: no-referrer.

Interactive packages are the exception to the shared policy: the frame that plays them is sandboxed without allow-same-origin, so the package runs in an opaque origin, and the API answers each file with a policy of its own (no 'unsafe-eval', connect-src 'self' unless the tenant allows the manifest’s network list, frame-ancestors limited to the tenant front and admin). Keep /interactive/* in the proxy’s package matcher and do not override the header there.

The API sites strip X-Ulams-Content-Origin from client requests, and the API answers 404 to /api/content/... without it, so package HTML never runs on the API origin.

Serving package files straight from the bucket through a CDN with the same headers also works; the API route is the simple default because it covers local and bucket disks alike.