Skip to content

Security

Course packages, H5P files, images and documents come from authors and imports and are treated as hostile input.

  • Upload guard (packages/uploads): every package and import goes through checks for zip-slip paths, symlinks, zip bombs (entry count, total and per-entry size, compression ratio), the sniffed MIME type and size limits (UPLOADS_*, see the environment reference).
  • Virus scanning is a hook, off by default (UPLOADS_SCANNER=null). To turn it on, run ClamAV (--profile av in the example) and set UPLOADS_SCANNER=clamd. With UPLOADS_CLAMD_FAIL_CLOSED=true (the default) uploads are rejected while clamd is unreachable.
  • Package code runs on the content origin only: SCORM, cmi5, Adapt and LiaScript files are served from a per-tenant host (a separate registrable domain, or a same-site subdomain with the __Host- cookie, exact-Origin, sandbox and CORP/COOP mitigations), without cookies or API routes, with its own CSP. See Content origin.
  • H5P runs in its own service (api/h5p): the front and admin frame its embed pages and talk to them with postMessage; the embed pages send frame-ancestors limited to the tenant’s front and admin origins, and CORS is per tenant. The reference front keeps the learner’s token in an httpOnly cookie and adds it server-side, for the player’s own calls only, in its /h5p proxy, so H5P content scripts never see a token (H5P in production).
  • In buckets, ActiveContentSafeS3Adapter sets Content-Type from the file extension (an HTML file renamed to .png is not served as HTML) and stores SVG, HTML, XML and unknown types with Content-Disposition: attachment, so opening the URL downloads instead of rendering. <img> tags ignore that header, so SVG images still display. Package paths are exempt because they are played from the content origin.
  • On the storage host, Caddy adds X-Content-Type-Options: nosniff and, for active file types, Content-Security-Policy: script-src 'none'; sandbox. The development Caddyfile does this for SVG only; the production example extends it to HTML and XML.
  • Serve the storage host from the content domain, never from the application domain.
Origin Headers today Source
Content origins Enforced CSP, nosniff, Referrer-Policy: no-referrer, cookies stripped Caddy content_origin snippet
Learner front (web) nosniff, Referrer-Policy: strict-origin-when-cross-origin, same-origin check on state-changing requests (CSRF) front/web/src/middleware.ts
Learner front (web) Content-Security-Policy (enforced in development, report-only in the production image until CSP_ENFORCE=true) with frame-src for the tenant’s registered tools, and Reporting-Endpoints front/web/src/middleware.ts
Admin The same, with frame-src 'self' <content origins> https: Caddy app_csp_admin snippet
H5P embed pages frame-ancestors per tenant, Referrer-Policy: no-referrer api/h5p

Violation reports go to POST /api/csp-report and admins read them at GET /api/admin/csp-reports (Security headers).

Recommended Collect the CSP reports for about a week, then enforce the policy of the front and admin (see Security headers), and add Strict-Transport-Security once HTTPS works on every host. HSTS is not in the example by default.

  • Each tenant has its own PostgreSQL role and database, bucket, APP_KEY and Passport key pair: a token issued by one tenant is rejected by every other.
  • Requests for hosts that are neither platform hosts nor provisioned tenants get 404 (TENANCY_ENFORCE_HOSTS, on by default).
  • Shared between tenants: one Valkey instance (separated by key prefix only), one object-store access key (separated by bucket only), the H5P library volume, and the platform’s SMTP account.
  • The tenant’s internal H5P token is derived from its APP_KEY; the platform’s comes from H5P_INTERNAL_TOKEN.

Users and admins can create scoped API tokens for the CLI, agents and CI (API tokens, Scoped API tokens). Tokens are Passport tokens (one key pair per tenant), expire in at most 365 days and are never stored: the database holds only Passport’s token id. Every change a scoped token makes, and every read of user and report data, is written to agent_audit_log without request bodies and deleted after AUTH_AGENT_AUDIT_DAYS (default 365) by ulams:auth:prune-agent-audit, which runs from the scheduler (make sure schedule:run or ulams:tenant:schedule-loop is running).

After upgrading, run the migrations and the permission seeder for every tenant so the admin role gets token_manage:

Terminal window
php artisan ulams:tenant:sync-env --migrate
php artisan db:seed --class="Ulams\Auth\Database\Seeders\AuthPermissionSeeder" --domain=<tenant host>

AUTH_MAX_TOKENS_PER_USER (default 50) limits active tokens per user, AUTH_DEVICE_APPROVE_PER_MINUTE (default 5) the approval attempts of the CLI browser sign-in per user (the scheduler also runs ulams:auth:prune-device-authorizations hourly), APP_VERSION is what GET /api/meta reports.

  • APP_ENV=production and APP_DEBUG=false. With APP_ENV set to local or stage, the Horizon dashboard is open to anyone.
  • Never enable demo mode (--demo=on, DEMO_MODE=true) on a tenant with real data: it allows sign-in without a password and resets the tenant every hour.
  • TENANT_DEMO_PASSWORD is the initial password of every new tenant’s admin and tutor; set a strong value and rotate those passwords after provisioning (see First admin and first tenant).
  • LTI_ALLOW_INSECURE_URLS is for development only.

Publish only Caddy (80 and 443). PostgreSQL, Valkey, php-fpm (9000), the H5P service (8080), the PDF service (3000), MJML and the two internal Caddy listeners (8081 and 5555) must stay on the internal network. php-fpm and the H5P service pick the tenant from X-Forwarded-Host, which only the proxy may set.

APP_KEY, the platform key pair, database and Valkey passwords, internal tokens and S3 and SMTP credentials are in .env. Restrict its file mode, keep it out of version control, and keep a copy in a password manager: losing APP_KEY makes every tenant unrecoverable.