e2e.csrf.test.ts
⎇
Raw
1import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2import {
3 BASE,
4 ADMIN_PASS,
5 setupTestEnv,
6 spawnServer,
7 killServer,
8} from './helpers.ts';
9
10let server: Awaited<ReturnType<typeof spawnServer>>;
11
12// The Go server derives PUBLIC_HTTPS / PUBLIC_ORIGIN from BASE_URL at
13// startup, so the HTTPS-mode block restarts the server with a different
14// BASE_URL rather than mutating config in process.
15
16beforeAll(async () => {
17 await setupTestEnv();
18 server = await spawnServer();
19});
20
21afterAll(async () => {
22 await killServer(server);
23});
24
25// `bun:test` runs describe blocks in source order, so the dev-mode block runs
26// first against the default BASE_URL, then we restart in HTTPS mode.
27describe('CSRF / Secure cookie — dev mode (http BASE_URL)', () => {
28 test('starts in dev mode (no HSTS header)', async () => {
29 // PUBLIC_HTTPS is not readable out of process. The HSTS header is the
30 // observable signal that the server is in plain-http mode.
31 const r = await fetch(`${BASE}/health`);
32 expect(r.headers.get('strict-transport-security')).toBeNull();
33 });
34
35 test('POST with no Origin is allowed (non-browser path)', async () => {
36 const r = await fetch(`${BASE}/login`, {
37 method: 'POST',
38 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
39 body: 'username=admin&password=wrong',
40 redirect: 'manual',
41 });
42 expect(r.status).not.toBe(403);
43 });
44
45 test('POST with same-origin Origin is allowed', async () => {
46 const r = await fetch(`${BASE}/login`, {
47 method: 'POST',
48 headers: {
49 'Content-Type': 'application/x-www-form-urlencoded',
50 Origin: BASE,
51 },
52 body: 'username=admin&password=wrong',
53 redirect: 'manual',
54 });
55 expect(r.status).not.toBe(403);
56 });
57
58 test('POST with mismatched Origin is rejected', async () => {
59 const r = await fetch(`${BASE}/login`, {
60 method: 'POST',
61 headers: {
62 'Content-Type': 'application/x-www-form-urlencoded',
63 Origin: 'http://attacker.example',
64 },
65 body: 'username=admin&password=wrong',
66 redirect: 'manual',
67 });
68 expect(r.status).toBe(403);
69 });
70
71 test('successful login Set-Cookie omits Secure', async () => {
72 const r = await fetch(`${BASE}/login`, {
73 method: 'POST',
74 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
75 body: `username=admin&password=${encodeURIComponent(ADMIN_PASS)}`,
76 redirect: 'manual',
77 });
78 expect(r.status).toBe(302);
79 const cookie = r.headers.get('set-cookie') ?? '';
80 expect(cookie).toContain('session=');
81 expect(cookie).not.toContain('Secure');
82 });
83
84 test('responses do not include Strict-Transport-Security', async () => {
85 const r = await fetch(`${BASE}/health`);
86 expect(r.headers.get('strict-transport-security')).toBeNull();
87 });
88});
89
90describe('CSRF / Secure cookie — HTTPS mode (https BASE_URL)', () => {
91 beforeAll(async () => {
92 // Restart with `BASE_URL=https://forge.test`. Note that the test client
93 // still talks to the server over plain HTTP on localhost — that's the
94 // whole point of the reverse-proxy story: the app trusts BASE_URL, not
95 // the transport it sees on the proxy↔app hop.
96 await killServer(server);
97 server = await spawnServer({ BASE_URL: 'https://forge.test' });
98 });
99
100 test('POST with no Origin is allowed (non-browser path)', async () => {
101 const r = await fetch(`${BASE}/login`, {
102 method: 'POST',
103 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
104 body: 'username=admin&password=wrong',
105 redirect: 'manual',
106 });
107 expect(r.status).not.toBe(403);
108 });
109
110 test('POST with matching public Origin is allowed', async () => {
111 const r = await fetch(`${BASE}/login`, {
112 method: 'POST',
113 headers: {
114 'Content-Type': 'application/x-www-form-urlencoded',
115 Origin: 'https://forge.test',
116 },
117 body: 'username=admin&password=wrong',
118 redirect: 'manual',
119 });
120 expect(r.status).not.toBe(403);
121 });
122
123 test('POST whose Origin only matches Host (not BASE_URL) is rejected', async () => {
124 // Stricter than dev mode: `Origin: ${BASE}` (http://localhost:PORT) would
125 // pass the Host-match check but must fail the BASE_URL check.
126 const r = await fetch(`${BASE}/login`, {
127 method: 'POST',
128 headers: {
129 'Content-Type': 'application/x-www-form-urlencoded',
130 Origin: BASE,
131 },
132 body: 'username=admin&password=wrong',
133 redirect: 'manual',
134 });
135 expect(r.status).toBe(403);
136 });
137
138 test('POST with attacker Origin is rejected', async () => {
139 const r = await fetch(`${BASE}/login`, {
140 method: 'POST',
141 headers: {
142 'Content-Type': 'application/x-www-form-urlencoded',
143 Origin: 'https://attacker.example',
144 },
145 body: 'username=admin&password=wrong',
146 redirect: 'manual',
147 });
148 expect(r.status).toBe(403);
149 });
150
151 test('successful login Set-Cookie includes Secure', async () => {
152 const r = await fetch(`${BASE}/login`, {
153 method: 'POST',
154 headers: {
155 'Content-Type': 'application/x-www-form-urlencoded',
156 Origin: 'https://forge.test',
157 },
158 body: `username=admin&password=${encodeURIComponent(ADMIN_PASS)}`,
159 redirect: 'manual',
160 });
161 expect(r.status).toBe(302);
162 const cookie = r.headers.get('set-cookie') ?? '';
163 expect(cookie).toContain('session=');
164 expect(cookie).toContain('Secure');
165 });
166
167 test('responses include Strict-Transport-Security', async () => {
168 const r = await fetch(`${BASE}/health`);
169 expect(r.headers.get('strict-transport-security')).toBe(
170 'max-age=31536000; includeSubDomains',
171 );
172 });
173});
174