Please open a private security advisory through GitHub rather than a public issue.
Uploading a course is equivalent to running code on your site.
SCORM packages are ordinary HTML, CSS and JavaScript. To work at all, a package must run in the
browser and reach the LMS runtime (window.API) in its parent page, which means it runs on the
same origin as the application itself, unsandboxed. That is inherent to SCORM 1.2, not a shortcut
taken here.
The consequence: a malicious or compromised package can do anything the signed-in person's browser
can do on your site. It can read the pages they can read and make requests as them. Session cookies
are HttpOnly, so a package cannot read the cookie itself — but it does not need to, because the
browser attaches it to every request the package makes.
Practical rules:
- Only upload packages from vendors you trust. Treat a SCORM zip like a browser extension, not like a PDF.
- Only give administrator rights to people you would trust to deploy code. Course upload is an administrator capability, and it is a powerful one.
- Be especially careful with packages from a customer or a third party. "Can you load this course for us" is a request to run their code on your infrastructure.
Every self-hosted SCORM LMS shares this property. Commercial hosts mitigate it by serving package
content from a separate domain so a package cannot reach the application's own API. If you need
that isolation, serve /api/scorm/* from a different hostname behind your proxy and adjust
contentUrl in src/app/learn/[id]/page.tsx accordingly. It is not the default because it requires
a second hostname, which would stop the simple setup working out of the box.
Passwords are hashed with scrypt and a per-user salt. They are never emailed: new accounts and resets both use a one-time link, and only a SHA-256 hash of that link's token is stored, so a copy of the database does not yield working links. Links expire — a week for an invitation, two hours for a reset — and are single use.
Sessions are stateless, signed with HMAC-SHA256 over a server secret. The cookie is HttpOnly,
SameSite=Lax, and marked Secure whenever the connection is really HTTPS, detected from
x-forwarded-proto rather than assumed. SameSite=Lax is what protects state-changing requests
from cross-site forgery.
Sign-in is rate limited per account and per address: eight failures against one account, or twenty-five from one address, pause attempts for fifteen minutes.
Nothing that changes state answers a GET. Sign-out, sign-in, password changes and organization
switching are all POST. This is not pedantry: an earlier version had a GET /logout, and link
prefetching signed users out seconds after they arrived.
Tenants are isolated at every query. An administrator only ever sees their own organization's people and records. Only a platform-owner account can move between organizations, and doing so is written to the activity log.
Package files are served with a path-traversal guard and only to someone actually assigned that course, or an administrator of the organization that owns it. Uploads are extracted with a zip-slip guard.
The activity log is append-only. Nothing in the application updates or deletes a row in it.
Diagnostic endpoints (/api/session-debug, /api/cookie-test) return 404 unless DEBUG_TOKEN
is set and supplied. Leave it unset in normal operation.
- No two-factor authentication. If you need it, put an identity-aware proxy in front.
- No audit of administrator reads. Writes are logged; simply viewing a report is not.
- No encryption at rest beyond whatever your host provides. The SQLite file and the extracted packages are ordinary files.
- No virus scanning of uploads. Package contents are served back as-is.
- Completion records are as trustworthy as the course reporting them. The application records what the SCORM runtime is told; a package can tell it anything. This is why point 1 above matters.