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 <noreply@anthropic.com>
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
+42 -2
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { assignInitialRole } from "../src/server/auth";
import { expect, spyOn, test } from "bun:test";
import { assignInitialRole, isUniqueViolation } from "../src/server/auth";
import { syncClaimGroups, groupsFromClaims } from "../src/server/auth/groups";
import { forEachDb } from "./helpers";
import { cookieFrom, makeApp } from "./app";
@@ -60,3 +60,43 @@ test("groupsFromClaims reads arrays and strings", () => {
expect(groupsFromClaims({ extraFields: { groups: ["z"] } })).toEqual(["z"]);
expect(groupsFromClaims(null)).toEqual([]);
});
forEachDb("owner bootstrap fails closed: an injected failure refuses the sign-up, no silent learner, no free owner slot", async (db) => {
const t = await makeApp(db);
const count = async (q: string) => Number((await db.sql.unsafe(q))[0].n);
const err = spyOn(console, "error").mockImplementation(() => {});
try {
// The role table is unreachable: role assignment fails with a non-unique error.
await db.sql.unsafe("ALTER TABLE user_roles RENAME TO user_roles_off");
const signUp = (email: string) => t.call("POST", "/api/auth/sign-up/email", { body: { email, name: "X", password: "correct horse battery" } });
const first = await signUp("first@example.com");
expect(first.status).toBe(500);
// nothing is left: no account, no owner claim
expect(await count("SELECT COUNT(*) AS n FROM auth_user")).toBe(0);
expect(await count("SELECT COUNT(*) AS n FROM learner_meta WHERE key = 'owner'")).toBe(0);
// while the failure lasts, the next sign-up does not become owner either: it fails the same way
expect((await signUp("stranger@example.com")).status).toBe(500);
expect(await count("SELECT COUNT(*) AS n FROM auth_user")).toBe(0);
expect(await count("SELECT COUNT(*) AS n FROM learner_meta WHERE key = 'owner'")).toBe(0);
// once the database recovers, the first completed account is the owner, the next a learner
await db.sql.unsafe("ALTER TABLE user_roles_off RENAME TO user_roles");
const own = await t.signUp("first@example.com");
const lea = await t.signUp("stranger@example.com");
expect((await (await t.call("GET", "/api/me", { cookie: own })).json()).user.roles).toEqual(["owner"]);
expect((await (await t.call("GET", "/api/me", { cookie: lea })).json()).user.roles).toEqual(["learner"]);
} finally {
err.mockRestore();
}
}, 30_000);
forEachDb("assignInitialRole rethrows anything but a unique violation, and rolls the owner claim back", async (db) => {
await db.sql.unsafe("ALTER TABLE user_roles RENAME TO user_roles_off");
await expect(assignInitialRole(db, "u1")).rejects.toThrow();
expect((await db.sql`SELECT COUNT(*) AS n FROM learner_meta WHERE key = 'owner'`)[0].n.toString()).toBe("0");
await db.sql.unsafe("ALTER TABLE user_roles_off RENAME TO user_roles");
expect(await assignInitialRole(db, "u1")).toBe("owner");
expect(await assignInitialRole(db, "u2")).toBe("learner");
expect(isUniqueViolation({ code: "23505" })).toBe(true);
expect(isUniqueViolation(new Error("UNIQUE constraint failed: learner_meta.key"))).toBe(true);
expect(isUniqueViolation(new Error("no such table: user_roles"))).toBe(false);
});
@@ -0,0 +1,79 @@
import { expect, test } from "bun:test";
import { closeSync, mkdirSync, mkdtempSync, openSync, writeFileSync, writeSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CHUNK, SMALL_FILE, crc32Update, toStream, zipStream } from "../src/server/zip";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
const rnd = (n: number) => { const b = new Uint8Array(n); for (let i = 0; i < n; i += 65536) crypto.getRandomValues(b.subarray(i, Math.min(n, i + 65536))); return b; };
test("incremental CRC-32 matches Bun.hash.crc32", () => {
const data = rnd(300_000);
let c = 0;
for (let i = 0; i < data.length; i += 7777) c = crc32Update(c, data.subarray(i, i + 7777));
expect(c).toBe(Bun.hash.crc32(data) >>> 0);
});
test("the stream holds at most one chunk of a large file; unzip verifies every CRC", async () => {
const dir = mkdtempSync(join(tmpdir(), "stc-zs-"));
mkdirSync(join(dir, "Ünï sub"));
writeFileSync(join(dir, "big.bin"), rnd(SMALL_FILE * 3 + 12345)); // streamed, stored, data descriptor
writeFileSync(join(dir, "Ünï sub/small.txt"), "hello ".repeat(10_000)); // deflated
writeFileSync(join(dir, "empty.txt"), "");
const entries = ["big.bin", "Ünï sub/small.txt", "empty.txt"].map((p) => ({ name: `pkg/${p}`, path: join(dir, p), size: Bun.file(join(dir, p)).size }));
const out = join(dir, "out.zip");
const fd = openSync(out, "w");
let largest = 0;
for await (const chunk of zipStream(entries)) { largest = Math.max(largest, chunk.length); writeSync(fd, chunk); }
closeSync(fd);
expect(largest).toBeLessThanOrEqual(Math.max(CHUNK, 60_000)); // never the whole 12 MB file
const t = await Bun.$`unzip -t ${out}`.nothrow().text();
expect(t).toContain("No errors detected");
const x = join(dir, "x");
await Bun.$`unzip -q ${out} -d ${x}`;
expect(Bun.hash.crc32(new Uint8Array(await Bun.file(join(x, "pkg/big.bin")).arrayBuffer()))).toBe(Bun.hash.crc32(new Uint8Array(await Bun.file(join(dir, "big.bin")).arrayBuffer())));
expect(await Bun.file(join(x, "pkg/Ünï sub/small.txt")).text()).toBe("hello ".repeat(10_000));
});
test("toStream pulls one chunk at a time", async () => {
let produced = 0;
async function* gen() { for (let i = 0; i < 100; i++) { produced++; yield new Uint8Array(10); } }
const reader = toStream(gen()).getReader();
await reader.read();
await Bun.sleep(20);
expect(produced).toBeLessThanOrEqual(3); // not run ahead to 100
await reader.cancel();
});
forEachDb("a 300 MB code folder downloads as a valid streamed zip without buffering it", async (db) => {
const root = mkdtempSync(join(tmpdir(), "stc-bigcode-"));
mkdirSync(join(root, "code"), { recursive: true });
// sparse file: no disk or memory cost to create, 300 MB to read
const f = openSync(join(root, "code/huge.bin"), "w");
writeSync(f, new Uint8Array([1]), 0, 1, 300 * 1024 * 1024 - 1);
closeSync(f);
writeFileSync(join(root, "code/readme.md"), "# hi\n");
writeFileSync(join(root, "stcourse.yaml"), `schema: 1\nid: bigcode\ntitle: Big\nvisibility: public\nmodules:\n - id: m1\n title: M\n code: { dir: code }\n lessons: [{ id: l1, title: L, type: text, file: code/readme.md }]\n`);
const t = await makeApp(db, [FIXTURES, root]);
Bun.gc(true);
const before = process.memoryUsage().rss;
let peak = before;
const r = await t.call("GET", "/api/courses/bigcode/code/m1/zip");
expect(r.status).toBe(200);
expect(r.headers.get("content-length")).toBeNull(); // streamed
const out = join(root, "out.zip");
const w = Bun.file(out).writer();
const reader = r.body!.getReader();
let n = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
w.write(value);
if (++n % 64 === 0) { await w.flush(); peak = Math.max(peak, process.memoryUsage().rss); }
}
await w.end();
expect(Bun.file(out).size).toBeGreaterThan(300 * 1024 * 1024);
expect(peak - before).toBeLessThan(150 * 1024 * 1024); // a buffered zip would need > 300 MB
expect(await Bun.$`unzip -t ${out}`.nothrow().text()).toContain("No errors detected");
}, 120_000);