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 WintherandClaude Opus 5.5 committed 2026-10-30 15:42:17 +01:00
1 parent 69aaedcb43
commit 8045c80d93
24 files changed
+725 -85

No files matched your search

+49 -2
View File
@@ -22,6 +22,47 @@ export function canView(user: AuthUser | null, course: LoadedCourse, acl: { prin
const json = (body: unknown, status = 200) => Response.json(body, { status });
const WRITES = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/** The request's origin from `Origin`, else from `Referer`; null when neither names one ("null" counts as none). */
function requestOrigin(req: Request): string | null {
const o = req.headers.get("origin");
if (o && o !== "null") return o.toLowerCase();
const r = req.headers.get("referer");
if (r) { try { const u = new URL(r).origin; return u === "null" ? null : u; } catch { return null; } }
return null;
}
const hasBody = (req: Request) => {
const len = req.headers.get("content-length");
if (len !== null) return Number(len) > 0;
return req.headers.has("transfer-encoding") || req.body !== null;
};
/**
* Cross-site request guard for every state-changing request (POST, PUT, PATCH, DELETE), applied once here for all
* features, Better Auth included:
* - the `Origin` (or, without it, the `Referer`) must be one of `config.allowedOrigins` (publicUrl's origin plus
* STC_ALLOWED_ORIGINS); missing or foreign gives 403;
* - a request with a body must be `application/json`, or `multipart/form-data` on the feature's `multipartRoutes`.
* Forms and text/plain bodies (what a hostile page can send without a preflight) are refused with 403.
* Paths in a feature's `externalCallbacks` (identity-provider posts) are exempt and verify themselves.
* Returns the refusal, or null to proceed.
*/
export function crossSiteRefusal(req: Request, allowedOrigins: string[], opts: { multipart: boolean; external: boolean }): Response | null {
if (!WRITES.has(req.method) || opts.external) return null;
const origin = requestOrigin(req);
if (!origin || !allowedOrigins.includes(origin)) {
return json({ error: "cross-site request refused: the Origin must be this server's public URL (STC_PUBLIC_URL or STC_ALLOWED_ORIGINS)" }, 403);
}
if (hasBody(req)) {
const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
const ok = type === "application/json" || (opts.multipart && type === "multipart/form-data");
if (!ok) return json({ error: opts.multipart ? "send multipart/form-data or application/json" : "send application/json" }, 403);
}
return null;
}
export function createApp(ctx: ServerContext, features: Feature[]): { routes: Routes; fetch(req: Request): Promise<Response> } {
const acl = aclRepo(ctx.db);
@@ -48,19 +89,25 @@ export function createApp(ctx: ServerContext, features: Feature[]): { routes: Ro
},
};
const wrap = (h: RouteHandler) => async (req: Request & { params?: Record<string, string> }): Promise<Response> => {
// No configuration means no allowed origin: every write is refused (fail closed).
const allowed = ctx.config?.allowedOrigins ?? [];
const external = features.flatMap((f) => f.externalCallbacks ?? []);
const wrap = (h: RouteHandler, multipart: boolean) => async (req: Request & { params?: Record<string, string> }): Promise<Response> => {
const url = new URL(req.url);
const refused = crossSiteRefusal(req, allowed, { multipart, external: external.some((p) => url.pathname.startsWith(p)) });
if (refused) return refused;
const rc: RequestContext = { user: await ctx.auth.userFromRequest(req), params: req.params ?? {}, url };
return h(req, rc);
};
const routes: Routes = {};
for (const f of [core, ...features]) {
const multipart = new Set(f.multipartRoutes ?? []);
for (const [path, methods] of Object.entries(f.routes)) {
const slot = (routes[path] ??= {});
for (const [m, h] of Object.entries(methods) as [Method, RouteHandler][]) {
if (slot[m]) throw new Error(`Duplicate route ${m} ${path} (feature "${f.name}")`);
slot[m] = wrap(h);
slot[m] = wrap(h, multipart.has(path));
}
}
}
+18 -1
View File
@@ -8,6 +8,15 @@ export type ConfigInput = Pick<AppConfig, "dataDir"> & Partial<Omit<AppConfig, "
// Path lists are split on ":" only (paths may contain spaces); URL lists are trimmed.
const paths = (raw: string | undefined) => (raw ?? "").split(":").filter(Boolean);
/** `scheme://host[:port]` of a URL, lower-cased by URL parsing; null when it does not parse. */
export function originOf(u: string): string | null {
try {
const o = new URL(u).origin;
return o === "null" ? null : o;
} catch {
return null;
}
}
const urls = (raw: string | undefined) => (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
/**
@@ -24,13 +33,20 @@ export function resolveConfig(input: ConfigInput): AppConfig {
if (!courseDirs.some((c) => resolve(c) === resolve(d))) courseDirs.push(d);
}
const port = input.port ?? 3000;
const publicUrl = input.publicUrl ?? `http://localhost:${port}`;
const origins: string[] = [];
for (const o of [publicUrl, ...(input.allowedOrigins ?? [])]) {
const n = originOf(o);
if (n && !origins.includes(n)) origins.push(n);
}
return {
port,
databaseUrl: input.databaseUrl ?? "sqlite://./data/stcourses.db",
dataDir,
courseDirs,
secret: input.secret ?? DEV_SECRET,
publicUrl: input.publicUrl ?? `http://localhost:${port}`,
publicUrl,
allowedOrigins: origins,
importedCoursesDir,
studioReposDir: input.studioReposDir ?? resolve(dataDir, "studio", "repos"),
studioWorktreesDir,
@@ -71,6 +87,7 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
aiAllowedBaseUrls: urls(env.STC_AI_ALLOWED_BASE_URLS),
transcribeUrl: env.STC_TRANSCRIBE_URL || null,
python: env.STC_PYTHON || "python3",
allowedOrigins: [...urls(env.STC_ALLOWED_ORIGINS), ...urls(env.STC_TRUSTED_ORIGINS)],
});
}
+2 -2
View File
@@ -1,5 +1,5 @@
export { loadConfig, resolveConfig, cachePaths, type ConfigInput } from "./config";
export { loadConfig, resolveConfig, cachePaths, originOf, type ConfigInput } from "./config";
export { createJobQueue } from "./jobs";
export { serveMedia, contentType, contentDisposition, mediaSecurityHeaders, MEDIA_SANDBOX_CSP } from "./media";
export { createApp, canView, type Routes } from "./app";
export { createApp, canView, crossSiteRefusal, type Routes } from "./app";
export { composeServer, type ComposeOptions, type ComposedServer } from "./compose";
+1 -1
View File
@@ -33,7 +33,7 @@ test("factories get the context; the queue is built from the union of their jobs
try {
expect(String(early)).toMatch(/not ready/);
expect(await (await s.app.fetch(new Request("http://x/api/a"))).json()).toEqual({ who: "u", courses: 1 });
const { id } = (await (await s.app.fetch(new Request("http://x/api/b", { method: "POST" }))).json()) as { id: string };
const { id } = (await (await s.app.fetch(new Request("http://x/api/b", { method: "POST", headers: { origin: "http://localhost:3000" } }))).json()) as { id: string };
for (let i = 0; i < 200 && (await s.ctx.jobs.get(id))?.status !== "done"; i++) await Bun.sleep(10);
expect((await s.ctx.jobs.get(id))?.status).toBe("done");
expect(seen).toEqual(['a:{"n":1}']);
+84
View File
@@ -0,0 +1,84 @@
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"]);
});