Files
Lucas WintherandClaude Opus 5.5 8045c80d93 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]>
2026-10-30 15:42:17 +01:00

85 lines
5.3 KiB
TypeScript

import { expect, test } from "bun:test";
import type { AuthUser, Feature, ServerContext } from "@stc/contracts";
import { createApp, resolveConfig } from "../src";
const owner: AuthUser = { id: "o", email: "o@x", name: "O", roles: ["owner"], groups: [] };
const ran: string[] = [];
const feature: Feature = {
name: "t",
routes: {
"/api/do": { POST: async () => { ran.push("do"); return Response.json({ ok: true }); }, DELETE: async () => { ran.push("del"); return Response.json({ ok: true }); }, GET: () => Response.json({ ok: true }) },
"/api/up": { POST: async () => { ran.push("up"); return Response.json({ ok: true }); } },
"/api/auth/*": { POST: async () => { ran.push("auth"); return Response.json({ ok: true }); } },
},
multipartRoutes: ["/api/up"],
externalCallbacks: ["/api/auth/sso/saml2/sp/acs"],
};
const config = resolveConfig({ dataDir: "/tmp/x", publicUrl: "https://courses.example.org", allowedOrigins: ["http://192.168.1.5:3000"] });
const app = createApp({ config, auth: { userFromRequest: async () => owner } } as unknown as ServerContext, [feature]);
const send = (path: string, init: { method?: string; headers?: Record<string, string>; body?: BodyInit } = {}) =>
app.fetch(new Request(`https://courses.example.org${path}`, { method: init.method ?? "POST", headers: init.headers, body: init.body }));
const JSONH = { "content-type": "application/json" };
test("same-origin JSON writes run; GET needs no Origin", async () => {
ran.length = 0;
expect((await send("/api/do", { headers: { origin: "https://courses.example.org", ...JSONH }, body: "{}" })).status).toBe(200);
expect((await send("/api/do", { headers: { origin: "http://192.168.1.5:3000", ...JSONH }, body: "{}" })).status).toBe(200); // STC_ALLOWED_ORIGINS
expect((await send("/api/do", { headers: { referer: "https://courses.example.org/c/x", ...JSONH }, body: "{}" })).status).toBe(200); // Referer fallback
expect((await send("/api/do", { method: "DELETE", headers: { origin: "https://courses.example.org" } })).status).toBe(200); // no body: no type needed
expect((await send("/api/do", { method: "GET" })).status).toBe(200);
expect(ran).toEqual(["do", "do", "do", "del"]);
});
test("foreign, missing or opaque Origin is 403 and the handler never runs", async () => {
ran.length = 0;
for (const headers of <Record<string, string>[]>[
{ origin: "http://evil.example", "content-type": "text/plain" },
{ origin: "http://evil.example", ...JSONH },
{ origin: "https://courses.example.org.evil.example", ...JSONH },
{ origin: "http://courses.example.org", ...JSONH }, // scheme differs
{ origin: "null", ...JSONH },
{ referer: "http://evil.example/page", ...JSONH },
{ ...JSONH }, // neither Origin nor Referer
]) {
const r = await send("/api/do", { headers, body: "{}" });
expect([headers, r.status]).toEqual([headers, 403]);
}
expect((await send("/api/do", { method: "DELETE" })).status).toBe(403);
expect(ran).toEqual([]);
});
test("same-origin bodies must be JSON (multipart only on upload routes)", async () => {
ran.length = 0;
const o = { origin: "https://courses.example.org" };
for (const type of ["text/plain", "application/x-www-form-urlencoded", "multipart/form-data; boundary=x", ""]) {
expect([type, (await send("/api/do", { headers: { ...o, ...(type ? { "content-type": type } : {}) }, body: "a=1" })).status]).toEqual([type, 403]);
}
const fd = new FormData();
fd.set("file", new File(["x"], "a.txt"));
expect((await send("/api/up", { headers: o, body: fd })).status).toBe(200);
expect((await send("/api/up", { headers: { origin: "http://evil.example" }, body: fd })).status).toBe(403);
expect((await send("/api/up", { headers: { ...o, "content-type": "text/plain" }, body: "x" })).status).toBe(403);
expect(ran).toEqual(["up"]);
});
test("Better Auth routes are guarded too, except declared identity-provider callbacks", async () => {
ran.length = 0;
expect((await send("/api/auth/sign-in/email", { headers: { origin: "http://evil.example", ...JSONH }, body: "{}" })).status).toBe(403);
expect((await send("/api/auth/sign-in/email", { headers: { origin: "https://courses.example.org", ...JSONH }, body: "{}" })).status).toBe(200);
// a SAML IdP posts a form from its own origin; Better Auth verifies the signed assertion
expect((await send("/api/auth/sso/saml2/sp/acs/idp1", { headers: { origin: "https://idp.example", "content-type": "application/x-www-form-urlencoded" }, body: "SAMLResponse=x" })).status).toBe(200);
expect(ran).toEqual(["auth", "auth"]);
});
test("no configuration means every write is refused", async () => {
const bare = createApp({ auth: { userFromRequest: async () => owner } } as unknown as ServerContext, [feature]);
const r = await bare.fetch(new Request("http://x/api/do", { method: "POST", headers: { origin: "http://x", ...JSONH }, body: "{}" }));
expect(r.status).toBe(403);
});
test("loadConfig reads STC_ALLOWED_ORIGINS (and STC_TRUSTED_ORIGINS) as origins", async () => {
const { loadConfig } = await import("../src");
const c = loadConfig({ STC_SECRET: "s", STC_PUBLIC_URL: "https://Courses.Example.org/app/", STC_ALLOWED_ORIGINS: "http://lan:3000/, junk", STC_TRUSTED_ORIGINS: "https://b.example" });
expect(c.allowedOrigins).toEqual(["https://courses.example.org", "http://lan:3000", "https://b.example"]);
});