Security
Uploaded content is untrusted
Section titled “Uploaded content is untrusted”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 avin the example) and setUPLOADS_SCANNER=clamd. WithUPLOADS_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 withpostMessage; the embed pages sendframe-ancestorslimited 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/h5pproxy, so H5P content scripts never see a token (H5P in production).
SVG, HTML and other active files
Section titled “SVG, HTML and other active files”- In buckets,
ActiveContentSafeS3AdaptersetsContent-Typefrom the file extension (an HTML file renamed to.pngis not served as HTML) and stores SVG, HTML, XML and unknown types withContent-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: nosniffand, 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.
Security headers
Section titled “Security headers”| 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.
Tenant isolation
Section titled “Tenant isolation”- Each tenant has its own PostgreSQL role and database, bucket,
APP_KEYand 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 fromH5P_INTERNAL_TOKEN.
API tokens and the agent audit log
Section titled “API tokens and the agent audit log”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:
php artisan ulams:tenant:sync-env --migratephp 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.
Accounts and modes
Section titled “Accounts and modes”APP_ENV=productionandAPP_DEBUG=false. WithAPP_ENVset tolocalorstage, 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_PASSWORDis 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_URLSis for development only.
Network exposure
Section titled “Network exposure”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.
Secrets
Section titled “Secrets”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.