Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/web/tests/collections/collections-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { call, create, h, ownerKey } from "./collections-harness";

/**
* A collection write in a web session (no `Authorization`) rides the ambient
* cookie, so it must come from this origin; an API key is never ambient and is
* not checked.
*/
const session = vi.hoisted(() => ({ user: null as { id: string } | null }));
vi.mock("~/lib/auth/session-helpers", () => ({
getSession: async () => (session.user === null ? null : { user: session.user }),
}));

beforeEach(() => {
session.user = null;
});

const count = () => h.d1.raw.prepare("SELECT COUNT(*) AS n FROM collections").get() as { n: number };

describe("collections: same-origin web sessions", () => {
it("refuses a cross-origin web-session write on every write method, writing nothing", async () => {
session.user = { id: "user-a" };
for (const headers of [{ Origin: "https://evil.example" }, { Origin: "https://sub.skillx.test" }, { "Sec-Fetch-Site": "same-site" }]) {
const refused = await create({}, { key: null, headers });
expect(refused.status).toBe(403);
expect(refused.body.error.code).toBe("cross_origin");
}
expect(count().n).toBe(0);

const { id } = (await create({}, { key: ownerKey })).body.collection;
const foreign = { key: null, headers: { Origin: "https://evil.example" } };
for (const [path, body, method] of [
[`/api/v1/collections/${id}`, { name: "x" }, "PATCH"],
[`/api/v1/collections/${id}`, {}, "DELETE"],
[`/api/v1/collections/${id}/revisions`, { expectedRevision: 1, members: [] }, "POST"],
[`/api/v1/collections/${id}/members`, { expectedRevision: 1, remove: ["@alpha/demo"] }, "POST"],
[`/api/v1/collections/${id}/clone`, { slug: "copy" }, "POST"],
] as const) {
const refused = await call(path, body, { ...foreign, method });
expect(refused.status).toBe(403);
expect(refused.body.error.code).toBe("cross_origin");
}
const after = await call(`/api/v1/collections/${id}`, undefined, { key: ownerKey });
expect(after.body.collection).toMatchObject({ name: "Starter", latestRevisionNumber: 1 });
expect(count().n).toBe(1);
});

it("accepts a same-origin or header-less web-session write", async () => {
session.user = { id: "user-a" };
expect((await create({}, { key: null, headers: { Origin: "https://skillx.test" } })).status).toBe(201);
expect((await create({ slug: "second" }, { key: null, headers: { "Sec-Fetch-Site": "same-origin" } })).status).toBe(201);
expect((await create({ slug: "third" }, { key: null })).status).toBe(201);
});

it("does not check the origin of an API-key write", async () => {
expect((await create({}, { headers: { Origin: "https://evil.example" } })).status).toBe(201);
});
});
56 changes: 56 additions & 0 deletions apps/web/tests/personal-kits/kit-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BODY, createKitHarness, skillText } from "./kit-harness";
import type { KitHarness } from "./kit-harness";

/**
* A kit write in a web session (no `Authorization`) rides the ambient cookie,
* so it must come from this origin; an API key is never ambient and is not checked.
*/
const session = vi.hoisted(() => ({ user: null as { id: string } | null }));
vi.mock("~/lib/auth/session-helpers", () => ({
getSession: async () => (session.user === null ? null : { user: session.user }),
}));

let k: KitHarness;
const MINE = { "SKILL.md": skillText(BODY.replace("two\n", "two (mine)\n")), "notes.md": "my notes\n" };

beforeEach(async () => {
session.user = null;
k = await createKitHarness();
await k.publish("@alpha/demo", "1.0.0", { "SKILL.md": skillText(BODY) });
});

describe("personal kits: same-origin web sessions", () => {
it("refuses a cross-origin web-session write, then accepts the same write from this origin", async () => {
session.user = { id: "user-a" };
const write = async (headers: Record<string, string>) =>
k.call("revisions", { expectedGeneration: 0, idempotencyKey: k.idem(), document: k.doc([await k.entry("@alpha/demo", "1.0.0", MINE)]) }, { key: null, headers });
for (const headers of [{ Origin: "https://evil.example" }, { "Sec-Fetch-Site": "same-site" }]) {
const refused = await write(headers);
expect(refused.status).toBe(403);
expect(refused.body.error.code).toBe("cross_origin");
}
// Nothing was written: generation 0 is still the current one.
expect((await write({ Origin: "https://skillx.test" })).status).toBe(201);
});

it("refuses a cross-origin web-session write on every write sub-route", async () => {
session.user = { id: "user-a" };
for (const sub of ["proposals", "proposals/validate", "preview-update", "activate", "rollback", "rebase", "conflicts/c-1/resolve", "revisions/rev-1/reject", "revisions/rev-1/review", "revisions/rev-1/quarantine"]) {
const refused = await k.call(sub, {}, { key: null, headers: { Origin: "https://evil.example" } });
expect(refused.status, sub).toBe(403);
expect(refused.body.error.code, sub).toBe("cross_origin");
}
});

it("accepts a header-less web-session write", async () => {
session.user = { id: "user-a" };
const body = { expectedGeneration: 0, idempotencyKey: k.idem(), document: k.doc([await k.entry("@alpha/demo", "1.0.0", MINE)]) };
expect((await k.call("revisions", body, { key: null })).status).toBe(201);
});

it("does not check the origin of an API-key write", async () => {
const body = { expectedGeneration: 0, idempotencyKey: k.idem(), document: k.doc([await k.entry("@alpha/demo", "1.0.0", MINE)]) };
expect((await k.call("revisions", body, { headers: { Origin: "https://evil.example" } })).status).toBe(201);
});
});
13 changes: 0 additions & 13 deletions apps/web/tests/personal-kits/kit-revisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,6 @@ describe("personal kit revisions", () => {
expect(lock.body.documents).toHaveLength(1);
});

it("refuses a cross-origin web-session write, then accepts the same write from this origin", async () => {
session.user = { id: "user-a" };
const write = async (headers: Record<string, string>) =>
k.call("revisions", { expectedGeneration: 0, idempotencyKey: k.idem(), document: k.doc([await k.entry("@alpha/demo", "1.0.0", MINE)]) }, { key: null, headers });
for (const headers of [{ Origin: "https://evil.example" }, { "Sec-Fetch-Site": "same-site" }]) {
const refused = await write(headers);
expect(refused.status).toBe(403);
expect(refused.body.error.code).toBe("cross_origin");
}
// Nothing was written: generation 0 is still the current one.
expect((await write({ Origin: "https://skillx.test" })).status).toBe(201);
});

it("refuses a client compositionDigest that the pinned bytes do not give, writing nothing", async () => {
const bad = { ...(await k.entry("@alpha/demo", "1.0.0", MINE)), compositionDigest: `sha256:${"1".repeat(64)}` };
const res = await k.call("revisions", { expectedGeneration: 0, idempotencyKey: k.idem(), document: k.doc([bad]) });
Expand Down
Loading