Files
STCourses/packages/feature-studio/test/security.test.ts
T
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

268 lines
16 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { isAllowedRemote, ownerRequiredFor } from "../src/server/store";
import { ada, extGit, newStore, tempDir } from "./helpers";
import { setupApp } from "./app-helpers";
describe("C1: writes never follow symlinks", () => {
test("a dangling symlink is refused for save and resolve; the outside target is never created", async () => {
const { store } = await newStore();
await store.createCourse({ id: "c1", title: "C" }, ada);
const draft = await store.ensureDraft("c1", ada.id);
const outside = join(tempDir(), "pwned.txt");
symlinkSync(outside, join(draft, "evil.md"));
await expect(store.saveFiles("c1", ada, [{ path: "evil.md", content: "x" }], "x")).rejects.toThrow(/symlink/);
await expect(store.saveFiles("c1", ada, [{ path: "evil.md", content: "x", baseHash: null }], "x")).rejects.toThrow(/symlink/);
expect(existsSync(outside)).toBe(false);
});
test("a symlinked parent directory is refused", async () => {
const { store } = await newStore();
await store.createCourse({ id: "c1", title: "C" }, ada);
const draft = await store.ensureDraft("c1", ada.id);
const outsideDir = tempDir();
symlinkSync(outsideDir, join(draft, "sub"));
await expect(store.saveFiles("c1", ada, [{ path: "sub/x.md", content: "x" }], "x")).rejects.toThrow(/symlink/);
await expect(store.saveFiles("c1", ada, [{ path: "sub/deeper/x.md", content: "x" }], "x")).rejects.toThrow(/symlink/);
expect(existsSync(join(outsideDir, "x.md"))).toBe(false);
expect(existsSync(join(outsideDir, "deeper"))).toBe(false);
});
test("a symlink pulled in from a remote merge cannot be written through; deleting the link leaves the target", async () => {
const { store } = await newStore({ allowLocalRemotes: true });
await store.createCourse({ id: "c1", title: "C" }, ada);
const remote = join(tempDir(), "remote.git");
await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote);
await store.setRemote("c1", remote);
await store.sync("c1", ada); // push main
const outsideDir = tempDir();
const target = join(outsideDir, "target.md");
writeFileSync(target, "outside\n");
const other = join(tempDir(), "other");
await extGit(tempDir(), "clone", "-q", remote, other);
symlinkSync(target, join(other, "notes.md"));
symlinkSync(join(outsideDir, "new.md"), join(other, "dangling.md"));
await extGit(other, "add", "notes.md", "dangling.md");
await extGit(other, "commit", "-qm", "plant symlinks");
await extGit(other, "push", "-q", "origin", "main");
expect((await store.updateDraft("c1", ada, "remote")).ok).toBe(true);
await expect(store.saveFiles("c1", ada, [{ path: "notes.md", content: "pwned" }], "x")).rejects.toThrow(/symlink/);
await expect(store.saveFiles("c1", ada, [{ path: "dangling.md", content: "pwned" }], "x")).rejects.toThrow(/symlink/);
expect(readFileSync(target, "utf8")).toBe("outside\n");
expect(existsSync(join(outsideDir, "new.md"))).toBe(false);
expect((await store.saveFiles("c1", ada, [{ path: "notes.md", delete: true }], "rm link")).ok).toBe(true);
expect(readFileSync(target, "utf8")).toBe("outside\n");
});
test("resolve refuses to write through a symlink", async () => {
const { store } = await newStore();
await store.createCourse({ id: "c1", title: "C" }, ada);
const pub = store.paths("c1").published;
const cur = (await store.readFile("c1", ada.id, "lessons/welcome.md"))!;
await store.saveFiles("c1", ada, [{ path: "lessons/welcome.md", content: "# mine\n", baseHash: cur.hash }], "mine");
writeFileSync(join(pub, "lessons/welcome.md"), "# theirs\n");
await extGit(pub, "commit", "-qam", "theirs");
await store.updateDraft("c1", ada);
const outside = join(tempDir(), "p.txt");
symlinkSync(outside, join(store.paths("c1").draft(ada.id), "link.md"));
await expect(store.resolve("c1", ada, [{ path: "link.md", content: "x" }])).rejects.toThrow(/symlink/);
expect(existsSync(outside)).toBe(false);
});
});
describe("I1: remotes", () => {
test.each([
"/tmp/x.git", "../x.git", "x.git", "file:///tmp/x.git", "ext::sh -c touch% /tmp/p", "fd::3", "http://host/x.git", "git://host/x.git",
"-uoption", "https://", "https:///x", "git@:x", "git@host:-x", "ssh://host", "ssh://-oProxyCommand=x/y", "https://host/x y",
])("rejects %p", async (url) => {
expect(isAllowedRemote(url)).toBe(false);
const { store } = await newStore();
await store.createCourse({ id: "c1", title: "C" }, ada);
await expect(store.setRemote("c1", url)).rejects.toThrow();
expect(await store.remote("c1")).toBeNull();
});
test.each(["https://github.com/a/b.git", "https://user:[email protected]:8443/a/b.git", "ssh://[email protected]:22/a/b.git", "[email protected]:a/b.git"])("accepts %p", (url) => {
expect(isAllowedRemote(url)).toBe(true);
});
test("a remote configured outside the studio is re-checked before sync and update", async () => {
const { store } = await newStore();
await store.createCourse({ id: "c1", title: "C" }, ada);
const remote = join(tempDir(), "remote.git");
await extGit(tempDir(), "init", "-q", "--bare", "-b", "main", remote);
await extGit(store.paths("c1").bare, "remote", "add", "origin", remote);
expect(await store.sync("c1", ada)).toMatchObject({ ok: false, reason: "no-remote" });
expect(await store.updateDraft("c1", ada, "remote")).toMatchObject({ ok: false, reason: "no-remote" });
expect(await extGit(remote, "for-each-ref")).toBe("");
});
});
describe("I3: owner-only visibility", () => {
const imported = "schema: 1\nid: c1\ntitle: C\nvisibility: restricted\nsource: { kind: packt-video }\nmodules: []\n";
test("rule", () => {
expect(ownerRequiredFor(imported, imported)).toBeNull();
expect(ownerRequiredFor(imported, imported.replace("restricted", "public"))).toMatch(/owner/);
expect(ownerRequiredFor(imported, imported.replace("restricted", "signed-in").replace("packt-video", "authored"))).toMatch(/owner/);
expect(ownerRequiredFor(imported, imported.replace("source: { kind: packt-video }\n", ""))).toMatch(/source.kind/);
const authored = imported.replace("packt-video", "authored");
expect(ownerRequiredFor(authored, authored.replace("restricted", "public"))).toBeNull();
expect(ownerRequiredFor(authored, authored.replace("authored", "pdf-deck"))).toMatch(/source.kind/);
});
let env: Awaited<ReturnType<typeof setupApp>>;
afterEach(() => env?.stop());
test("publish by a non-owner is 403 when the merged manifest makes an imported course visible; the owner may", async () => {
env = await setupApp();
const { call, feature } = env;
await call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
// the published course is an import (as an importer-adopted course would be)
const pub = feature.store.paths("c1").published;
const m0 = readFileSync(join(pub, "stcourse.yaml"), "utf8").replace("kind: authored", "kind: packt-video");
writeFileSync(join(pub, "stcourse.yaml"), m0);
await extGit(pub, "commit", "-qam", "imported");
await feature.watcher.check();
const ada = { id: "u-ada", name: "Ada", email: "[email protected]" };
// save-time check stops the plain attempt
const st = (await call("ada", "GET", "/api/studio/courses/c1")).json;
expect((await call("ada", "POST", "/api/studio/courses/c1/update-draft")).status).toBe(200);
const st2 = (await call("ada", "GET", "/api/studio/courses/c1")).json;
expect((await call("ada", "PUT", "/api/studio/courses/c1/files", { message: "x", manifest: { ...st2.manifestRaw, visibility: "public" }, manifestBaseHash: st2.manifestHash, files: [] })).status).toBe(403);
void st;
// a draft that got there another way (AI draft writer, merge) is stopped at publish
for (const edit of [(t: string) => t.replace("visibility: restricted", "visibility: public"), (t: string) => t.replace("visibility: restricted", "visibility: public").replace("kind: packt-video", "kind: authored")]) {
const cur = (await feature.store.readFile("c1", ada.id, "stcourse.yaml"))!;
await feature.store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: edit(cur.content), baseHash: cur.hash }], "sneak");
const r = await call("ada", "POST", "/api/studio/courses/c1/publish");
expect(r.status).toBe(403);
expect(r.json.reason).toBe("forbidden");
expect(readFileSync(join(pub, "stcourse.yaml"), "utf8")).toBe(m0);
expect(await extGit(pub, "status", "--porcelain")).toBe("");
await feature.store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: cur.content }], "undo");
}
// the owner may
const own = (await call("owner", "GET", "/api/studio/courses/c1")).json;
expect((await call("owner", "PUT", "/api/studio/courses/c1/files", { message: "public", manifest: { ...own.manifestRaw, visibility: "public" }, manifestBaseHash: own.manifestHash, files: [] })).status).toBe(200);
expect((await call("owner", "POST", "/api/studio/courses/c1/publish")).status).toBe(200);
expect(readFileSync(join(pub, "stcourse.yaml"), "utf8")).toContain("visibility: public");
});
test("resolve of the manifest by a non-owner is checked too", async () => {
const { store } = await newStore();
await store.createCourse({ id: "c1", title: "C" }, ada);
const pub = store.paths("c1").published;
const cur = (await store.readFile("c1", ada.id, "stcourse.yaml"))!;
await store.saveFiles("c1", ada, [{ path: "stcourse.yaml", content: cur.content.replace("title: C", "title: Mine"), baseHash: cur.hash }], "mine");
writeFileSync(join(pub, "stcourse.yaml"), cur.content.replace("title: C", "title: Theirs").replace("kind: authored", "kind: packt-video"));
await extGit(pub, "commit", "-qam", "theirs");
expect((await store.updateDraft("c1", ada)).ok).toBe(false);
const bad = cur.content.replace("kind: authored", "kind: packt-video").replace("visibility: restricted", "visibility: public");
await expect(store.resolve("c1", ada, [{ path: "stcourse.yaml", content: bad }])).rejects.toThrow(/owner/);
expect((await store.resolve("c1", { ...ada, owner: true }, [{ path: "stcourse.yaml", content: bad }])).remaining).toEqual([]);
});
});
describe("I2: uploads", () => {
let env: Awaited<ReturnType<typeof setupApp>>;
afterEach(() => env?.stop());
const upload = (name: string, bytes: Uint8Array<ArrayBuffer>, extra: Record<string, string> = {}) => {
const fd = new FormData();
fd.set("file", new File([bytes], name));
fd.set("dir", "media");
for (const [k, v] of Object.entries(extra)) fd.set(k, v);
return env.call("ada", "POST", "/api/studio/courses/c1/upload", fd);
};
test("type allowlist, manifest refused, no silent overwrite", async () => {
env = await setupApp();
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
expect((await upload("tool.exe", new Uint8Array([1]))).status).toBe(415);
expect((await upload("page.HTM", new Uint8Array([1]))).status).toBe(415);
expect((await upload("x", new Uint8Array([1]), { path: "stcourse.yaml" })).status).toBe(400);
expect((await upload("slides.pdf", new Uint8Array([1]))).status).toBe(200);
const again = await upload("slides.pdf", new Uint8Array([2]));
expect(again.status).toBe(409);
expect(readFileSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/media/slides.pdf"))[0]).toBe(1);
expect((await upload("slides.pdf", new Uint8Array([2]), { overwrite: "true" })).status).toBe(200);
expect(readFileSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/media/slides.pdf"))[0]).toBe(2);
});
test("size cap from AppConfig.uploadMaxMb (STC_UPLOAD_MAX_MB), by Content-Length and while streaming", async () => {
env = await setupApp([], "sqlite://:memory:", undefined, { uploadMaxMb: 1 });
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
expect((await upload("big.zip", new Uint8Array(1024 * 1024 + 10))).status).toBe(413);
// no Content-Length: a streamed body is cut off once it passes the cap
const fd = new FormData();
fd.set("file", new File([new Uint8Array(2 * 1024 * 1024)], "big.zip"));
const ct = new Response(fd).headers.get("content-type")!;
const bytes = new Uint8Array(await new Response(fd).arrayBuffer());
const stream = new ReadableStream({ start(c) { for (let i = 0; i < bytes.length; i += 65536) c.enqueue(bytes.slice(i, i + 65536)); c.close(); } });
const r = await env.app.fetch(new Request("http://x/api/studio/courses/c1/upload", { method: "POST", headers: { "x-user": "ada", origin: "http://x", "content-type": ct }, body: stream, duplex: "half" } as RequestInit));
expect(r.status).toBe(413);
expect(existsSync(join(env.dataDir, "worktrees/c1/drafts/u-ada/big.zip"))).toBe(false);
expect((await upload("small.zip", new Uint8Array(1000))).status).toBe(200);
});
test("raw draft files are served with a sandboxing CSP", async () => {
env = await setupApp();
await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" });
await upload("x.svg", new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>"));
const r = await env.app.fetch(new Request("http://x/api/studio/courses/c1/raw/media/x.svg", { headers: { "x-user": "ada" } }));
expect(r.headers.get("content-security-policy")).toContain("sandbox");
});
});
describe("I4: layout and imported courses", () => {
let env: Awaited<ReturnType<typeof setupApp>>;
afterEach(() => env?.stop());
test("imported courses in <dataDir>/courses are read-only with their own message; ids cannot collide", async () => {
env = await setupApp([], undefined, (dataDir) => {
mkdirSync(join(dataDir, "courses/imp/lessons"), { recursive: true });
writeFileSync(join(dataDir, "courses/imp/lessons/a.md"), "# A\n");
writeFileSync(join(dataDir, "courses/imp/stcourse.yaml"), "schema: 1\nid: imp\ntitle: Imported\nsource: { kind: packt-video }\nmodules:\n - { id: m, title: M, lessons: [{ id: a, title: A, type: text, file: lessons/a.md }] }\n");
});
expect(env.ctx.courses.get("imp")?.status).toBe("ok");
const list = (await env.call("ada", "GET", "/api/studio/courses")).json;
expect(list.courses).toContainEqual(expect.objectContaining({ id: "imp", managed: false, message: "Imported course: read-only in the studio for now" }));
expect((await env.call("ada", "GET", "/api/studio/courses/imp")).json.message).toBe("Imported course: read-only in the studio for now");
const w = await env.call("ada", "PUT", "/api/studio/courses/imp/files", { message: "x", files: [{ path: "a.md", content: "x" }] });
expect(w.status).toBe(403);
expect(w.json.error).toBe("Imported course: read-only in the studio for now");
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "imp", title: "Dup" })).status).toBe(409);
// studio repos are not in the importer's folder
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" })).status).toBe(201);
expect(existsSync(join(env.dataDir, "studio/repos/c1.git/HEAD"))).toBe(true);
expect(existsSync(join(env.dataDir, "courses/c1"))).toBe(false);
});
test("store refuses an id already used by an importer folder", async () => {
const { store, dataDir } = await newStore();
mkdirSync(join(dataDir, "courses/imp"), { recursive: true });
await expect(store.createCourse({ id: "imp", title: "x" }, ada)).rejects.toThrow(/exists/);
});
});
describe("I5: listing has no side effects", () => {
let env: Awaited<ReturnType<typeof setupApp>>;
afterEach(() => env?.stop());
test("GET /api/studio/courses creates no draft worktree or branch; opening the editor does", async () => {
env = await setupApp();
await env.call("owner", "POST", "/api/studio/courses", { id: "c1", title: "C" });
const bare = env.feature.store.paths("c1").bare;
const draft = env.feature.store.paths("c1").draft("u-ada");
const list = await env.call("ada", "GET", "/api/studio/courses");
expect(list.json.courses[0]).toMatchObject({ id: "c1", ahead: 0, behind: 0, merging: false });
expect(existsSync(draft)).toBe(false);
expect(await extGit(bare, "for-each-ref", "--format=%(refname)", "refs/heads/draft")).toBe("");
await env.call("ada", "GET", "/api/studio/courses/c1");
expect(existsSync(draft)).toBe(true);
});
});