Skip to content
File

Blob: tests/worker/origin-csrf.test.ts

typescript131 lines
1import { env, exports } from "cloudflare:workers";
2import { beforeAll, describe, expect, it } from "vitest";
3 
4import { makeAuth } from "@/worker/auth";
5 
6import { ISSUER, signInForCookie, testHeaders } from "./helpers";
7 
8const SELF = exports.default;
9 
10const credential = {
11 email: "origin-csrf-tester@example.com",
12 password: "correct-horse-battery-staple",
13 name: "Origin CSRF Tester",
14};
15 
16describe("Origin guard for tessera-owned mutations", () => {
17 let adminCookie: string;
18 
19 beforeAll(async () => {
20 const auth = makeAuth(env, { baseURL: ISSUER, issuer: ISSUER });
21 await auth.api.signUpEmail({ body: credential, asResponse: false });
22 await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin", credential.email).run();
23 adminCookie = await signInForCookie(credential.email, credential.password, "10.73.0.1");
24 });
25 
26 describe("POST /api/account/handle", () => {
27 it("403s on missing Origin (before requireUser)", async () => {
28 const res = await SELF.fetch(`${ISSUER}/api/account/handle`, {
29 method: "POST",
30 headers: { "content-type": "application/json" },
31 body: JSON.stringify({ preferredUsername: "anything" }),
32 });
33 expect(res.status).toBe(403);
34 const body = (await res.json()) as { error?: string };
35 expect(body.error).toBe("forbidden_origin");
36 });
37 
38 it("403s on foreign Origin even with valid session", async () => {
39 const res = await SELF.fetch(`${ISSUER}/api/account/handle`, {
40 method: "POST",
41 headers: testHeaders({ origin: "https://evil.example.com", cookie: adminCookie }),
42 body: JSON.stringify({ preferredUsername: "anything" }),
43 });
44 expect(res.status).toBe(403);
45 });
46 
47 it("passes with matching Origin and a valid session", async () => {
48 const res = await SELF.fetch(`${ISSUER}/api/account/handle`, {
49 method: "POST",
50 headers: testHeaders({ cookie: adminCookie }),
51 body: JSON.stringify({ preferredUsername: "matching-origin" }),
52 });
53 expect([200, 204]).toContain(res.status);
54 });
55 
56 it("400s on text/plain JSON body (content-type guard)", async () => {
57 const res = await SELF.fetch(`${ISSUER}/api/account/handle`, {
58 method: "POST",
59 headers: testHeaders({ "content-type": "text/plain", cookie: adminCookie }),
60 body: JSON.stringify({ preferredUsername: "wrong-type" }),
61 });
62 expect(res.status).toBe(400);
63 const body = (await res.json()) as { error?: string };
64 expect(body.error).toBe("invalid_content_type");
65 });
66 
67 it("accepts application/json; charset=utf-8", async () => {
68 const res = await SELF.fetch(`${ISSUER}/api/account/handle`, {
69 method: "POST",
70 headers: testHeaders({ "content-type": "application/json; charset=utf-8", cookie: adminCookie }),
71 body: JSON.stringify({ preferredUsername: "charset-utf8" }),
72 });
73 expect([200, 204]).toContain(res.status);
74 });
75 });
76 
77 describe("POST /api/admin/clients", () => {
78 it("403s on missing Origin (before requireAdmin)", async () => {
79 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
80 method: "POST",
81 headers: { "content-type": "application/json" },
82 body: JSON.stringify({ name: "x", redirectUris: ["http://127.0.0.1/cb"] }),
83 });
84 expect(res.status).toBe(403);
85 });
86 
87 it("passes GET without Origin (safe method skips the gate)", async () => {
88 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, {
89 method: "GET",
90 headers: { cookie: adminCookie },
91 });
92 expect(res.status).toBe(200);
93 });
94 });
95 
96 describe("readOptionalJsonBody empty-body fallback", () => {
97 it("passes admin invites POST with no body and no content-type if body is required", async () => {
98 // handleCreateInvite requires `email`; the handler returns 400
99 // invalid_body, NOT 400 invalid_content_type, when called with no
100 // body. This proves readOptionalJsonBody's fallback path bypasses
101 // the content-type guard for an empty body.
102 const res = await SELF.fetch(`${ISSUER}/api/admin/invites`, {
103 method: "POST",
104 headers: { origin: ISSUER, cookie: adminCookie, "content-length": "0" },
105 });
106 expect(res.status).toBe(400);
107 const body = (await res.json()) as { error?: string };
108 expect(body.error).toBe("invalid_body");
109 });
110 });
111 
112 describe("POST /api/auth/oauth2/token (excluded from tessera origin guard)", () => {
113 it("foreign Origin reaches Better Auth's catchall (no 403 from tessera)", async () => {
114 // OAuth token endpoint must remain accessible to RPs that originate
115 // from registered redirect URIs. tessera's origin guard must not
116 // intercept this path. Better Auth's own auth.handler applies its
117 // OAuth-specific checks; the response status reflects those, not
118 // a tessera 403.
119 const res = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
120 method: "POST",
121 headers: {
122 "content-type": "application/x-www-form-urlencoded",
123 origin: "https://rp.example.com",
124 },
125 body: new URLSearchParams({ grant_type: "authorization_code", code: "no-such-code" }).toString(),
126 });
127 expect(res.status).not.toBe(403);
128 });
129 });
130});