- 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]>
85 lines
5.3 KiB
TypeScript
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"]);
|
|
});
|