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