fix: final-review wave (CSRF guard, fail-closed owner bootstrap, streamed zips, e2e visibility suite)

- Critical 1 (CSRF): core wrap refuses every POST/PUT/PATCH/DELETE whose Origin
  (Referer as fallback) is not publicUrl's origin or STC_ALLOWED_ORIGINS, and
  any body that is not application/json (multipart only on declared upload
  routes). Better Auth routes go through the same guard; only declared
  identity-provider callbacks (SAML ACS/SLO, OAuth callbacks) are exempt.
  Better Auth trustedOrigins use the same list. Session cookie stays Lax
  (Strict breaks OAuth/OIDC/SAML returns); documented.
- Important 4 / learner minor 4: the first-owner claim and the role row are
  one transaction guarded by "no owner yet"; only a unique violation means
  learner; any other error fails the sign-up with 500 and removes the
  half-created user. Serialised per DB handle (Bun's SQLite connection).
- Important 2 / learner minor 12: code zips stream entry by entry (large
  files stored in 256 KB chunks with a data descriptor and incremental
  CRC-32), so memory stays at one chunk; the cap is now 1 GiB (ZIP32).
- Important 3 / learner minor 10: the e2e runs the production wiring
  (productFeatures order, AI drafts through the real sink) against a
  restricted course and asserts 404/401 for learner and anonymous on
  course, lesson, media, cover, code, zip, notes, progress, attempts, AI
  chat, library, search and AI status; a real Range request (206, 416);
  forged writes (foreign Origin, text/plain, no Origin) are 403 and the MCP
  test command never runs.
- Minors 7-9: compose passes every documented variable; the health check
  uses STC_PORT; productFeatures checks the studio-before-AI order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:42:17 +01:00
co-authored by Claude Opus 5.5
parent 69aaedcb43
commit 8045c80d93
24 changed files with 725 additions and 85 deletions
+16 -1
View File
@@ -50,11 +50,26 @@ Back up the `stc-data` volume (and the Postgres volume `stc-pg` when you use Pos
| `STC_AI_ALLOWED_BASE_URLS` | unset | `,`-separated origins learners' own AI keys may use besides api.anthropic.com and api.openai.com |
| `STC_AUTH_<GITHUB/GOOGLE/MICROSOFT/GITLAB/DISCORD>_ID`, `_SECRET` | unset | social sign-in |
| `STC_AUTH_OIDC_ISSUER`, `_ID`, `_SECRET`, `_NAME`, `_SCOPES`, `_DISCOVERY_URL`, `STC_AUTH_GROUPS_CLAIM` | unset | generic OIDC sign-in; group claims drive restricted visibility |
| `STC_TRUSTED_ORIGINS` | unset | extra `,`-separated origins allowed to call the sign-in API |
| `STC_ALLOWED_ORIGINS` | unset | extra `,`-separated origins (e.g. `http://192.168.1.5:3000`) allowed to send writes and sign in, besides `STC_PUBLIC_URL`'s origin. `STC_TRUSTED_ORIGINS` is read as an alias |
Without Docker: `bun install`, then `STC_SECRET=… NODE_ENV=production bun apps/server/src/main.ts`. The importer needs
`ffmpeg`, `ffprobe`, `git`, `unzip` and Python with PyMuPDF on the host.
## Cross-site requests and the session cookie
- Every state-changing request (POST, PUT, PATCH, DELETE), sign-in included, must carry an `Origin` (or, without
one, a `Referer`) equal to `STC_PUBLIC_URL`'s origin or one in `STC_ALLOWED_ORIGINS`. Anything else gets 403.
Open STCourses at exactly that URL. If people reach it by several names (a LAN IP and a hostname), list the others
in `STC_ALLOWED_ORIGINS`.
- A write with a body must be `application/json` (uploads: `multipart/form-data`). HTML forms and `text/plain`
bodies, which another page can send without a CORS preflight, are refused.
- Exempt: the identity-provider callbacks (SAML ACS and SLO, OAuth/OIDC callbacks under `/api/auth`). The provider
posts those from its own origin, and Better Auth verifies the signed assertion or the OAuth state itself.
- The session cookie stays `SameSite=Lax` (Better Auth's default), not `Strict`. OAuth/OIDC sign-in returns from
the provider by a cross-site top-level navigation, and Better Auth needs its state cookie on that request. Under
`Strict` the browser withholds it, and social, OIDC and SAML sign-in break. The Origin check above covers the
same-site cases that `Lax` allows (other ports on the same host, sibling subdomains).
## Security headers
- The web app carries its Content-Security-Policy as a `<meta>` tag in `apps/web/index.html` (no inline script).