Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

csrf.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

csrf.test.tsBlame227 lines · 1 contributor
4127ecfClaude1/**
2 * CSRF middleware — same-origin (Origin/Referer) defence + double-submit
3 * cookie token fallback.
4 *
5 * The platform was previously broken in production for logged-in users:
6 * the global `csrfProtect` middleware required a token on every POST but
7 * ~40+ web-UI forms didn't include one, so every authenticated form
8 * submission returned 403. The middleware now accepts a request iff EITHER:
9 *
10 * 1) Origin/Referer header matches the request host (same-origin), OR
11 * 2) The legacy double-submit cookie token check passes.
12 *
13 * These tests lock in that behaviour so a future refactor cannot silently
14 * regress it.
15 */
16
17import { describe, it, expect } from "bun:test";
18import { Hono } from "hono";
19import { csrfToken, csrfProtect } from "../middleware/csrf";
20
21function buildApp() {
22 const app = new Hono();
23 app.use("*", csrfToken);
24 app.use("*", csrfProtect);
25 app.get("/echo", (c) => c.text("ok"));
26 app.post("/echo", (c) => c.text("ok"));
27 return app;
28}
29
30describe("csrfProtect — request-method gating", () => {
31 it("lets GET requests through unconditionally", async () => {
32 const app = buildApp();
33 const res = await app.request("/echo");
34 expect(res.status).toBe(200);
35 });
36
37 it("lets HEAD/OPTIONS pass the csrf gate (does not 403)", async () => {
38 const app = buildApp();
39 const head = await app.request("/echo", { method: "HEAD" });
40 const opts = await app.request("/echo", { method: "OPTIONS" });
41 // The middleware does not return 403 — any non-200 status here means
42 // the route handler didn't accept the method, not that CSRF rejected.
43 expect(head.status).not.toBe(403);
44 expect(opts.status).not.toBe(403);
45 });
46});
47
48describe("csrfProtect — skip paths", () => {
49 it("skips when no session cookie is present (anonymous user)", async () => {
50 const app = buildApp();
51 // No `session` cookie → anonymous → not a CSRF target
52 const res = await app.request("/echo", { method: "POST" });
53 expect(res.status).toBe(200);
54 });
55
56 it("skips API routes (/api/*) regardless of method", async () => {
57 const app = new Hono();
58 app.use("*", csrfToken);
59 app.use("*", csrfProtect);
60 app.post("/api/anything", (c) => c.text("ok"));
61
62 const res = await app.request("/api/anything", {
63 method: "POST",
64 headers: { cookie: "session=fake-session-cookie" },
65 });
66 expect(res.status).toBe(200);
67 });
68
69 it("skips Bearer-authenticated requests", async () => {
70 const app = buildApp();
71 const res = await app.request("/echo", {
72 method: "POST",
73 headers: {
74 cookie: "session=fake",
75 authorization: "Bearer some-token-here",
76 },
77 });
78 expect(res.status).toBe(200);
79 });
80});
81
82describe("csrfProtect — same-origin defence (Origin/Referer)", () => {
83 it("accepts POST when Origin matches request host", async () => {
84 const app = buildApp();
85 const res = await app.request("/echo", {
86 method: "POST",
87 headers: {
88 cookie: "session=fake",
89 host: "gluecron.example.com",
90 origin: "https://gluecron.example.com",
91 },
92 });
93 expect(res.status).toBe(200);
94 });
95
96 it("accepts POST when Origin matches host with a non-default port", async () => {
97 const app = buildApp();
98 const res = await app.request("/echo", {
99 method: "POST",
100 headers: {
101 cookie: "session=fake",
102 host: "localhost:3000",
103 origin: "http://localhost:3000",
104 },
105 });
106 expect(res.status).toBe(200);
107 });
108
109 it("accepts POST when Referer matches host (browser may strip Origin)", async () => {
110 const app = buildApp();
111 const res = await app.request("/echo", {
112 method: "POST",
113 headers: {
114 cookie: "session=fake",
115 host: "gluecron.example.com",
116 referer: "https://gluecron.example.com/some/page",
117 },
118 });
119 expect(res.status).toBe(200);
120 });
121
122 it("rejects POST when Origin is an attacker site", async () => {
123 const app = buildApp();
124 const res = await app.request("/echo", {
125 method: "POST",
126 headers: {
127 cookie: "session=fake",
128 host: "gluecron.example.com",
129 origin: "https://evil.example.com",
130 },
131 });
132 expect(res.status).toBe(403);
133 });
134
135 it("rejects POST when Referer is an attacker site", async () => {
136 const app = buildApp();
137 const res = await app.request("/echo", {
138 method: "POST",
139 headers: {
140 cookie: "session=fake",
141 host: "gluecron.example.com",
142 referer: "https://evil.example.com/page",
143 },
144 });
145 expect(res.status).toBe(403);
146 });
147
148 it("rejects POST when Origin and Referer are both absent and no token cookie", async () => {
149 const app = buildApp();
150 const res = await app.request("/echo", {
151 method: "POST",
152 headers: { cookie: "session=fake" },
153 });
154 expect(res.status).toBe(403);
155 });
156
157 it("rejects POST when Origin is a malformed URL", async () => {
158 const app = buildApp();
159 const res = await app.request("/echo", {
160 method: "POST",
161 headers: {
162 cookie: "session=fake",
163 host: "gluecron.example.com",
164 origin: "::::not a url::::",
165 },
166 });
167 expect(res.status).toBe(403);
168 });
169});
170
171describe("csrfProtect — double-submit token fallback", () => {
172 it("accepts POST with matching token in header even when Origin is missing", async () => {
173 const app = buildApp();
174 const res = await app.request("/echo", {
175 method: "POST",
176 headers: {
177 cookie: "session=fake; csrf_token=abc123",
178 "x-csrf-token": "abc123",
179 },
180 });
181 expect(res.status).toBe(200);
182 });
183
184 it("rejects POST with mismatched token", async () => {
185 const app = buildApp();
186 const res = await app.request("/echo", {
187 method: "POST",
188 headers: {
189 cookie: "session=fake; csrf_token=abc123",
190 "x-csrf-token": "wrong-token",
191 },
192 });
193 expect(res.status).toBe(403);
194 });
195
196 it("accepts POST with matching token in form body", async () => {
197 const app = buildApp();
198 const body = new URLSearchParams({ _csrf: "abc123", other: "field" });
199 const res = await app.request("/echo", {
200 method: "POST",
201 headers: {
202 cookie: "session=fake; csrf_token=abc123",
203 "content-type": "application/x-www-form-urlencoded",
204 },
205 body: body.toString(),
206 });
207 expect(res.status).toBe(200);
208 });
209});
210
211describe("csrfToken — cookie setter", () => {
212 it("sets a csrf_token cookie on first request when none exists", async () => {
213 const app = buildApp();
214 const res = await app.request("/echo");
215 const setCookie = res.headers.get("set-cookie") || "";
216 expect(setCookie).toContain("csrf_token=");
217 });
218
219 it("does not overwrite an existing csrf_token cookie", async () => {
220 const app = buildApp();
221 const res = await app.request("/echo", {
222 headers: { cookie: "csrf_token=existing-value" },
223 });
224 const setCookie = res.headers.get("set-cookie") || "";
225 expect(setCookie).not.toContain("csrf_token=");
226 });
227});