Skip to content

Commit 5b0a920

Browse files
Varixowmertens
authored andcommitted
test: add more csrf unit tests, handle missing origin
1 parent 6a8167c commit 5b0a920

File tree

2 files changed

+178
-2
lines changed

2 files changed

+178
-2
lines changed

packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ function checkCSRF(requestEv: RequestEvent, laxProto?: true) {
2121
if (isForm) {
2222
const inputOrigin = requestEv.request.headers.get('origin');
2323
const origin = requestEv.url.origin;
24+
25+
// Reject requests with missing origin headers for form submissions
26+
if (!inputOrigin) {
27+
throw requestEv.error(
28+
403,
29+
`CSRF check failed. Cross-site ${requestEv.method} form submissions are forbidden.
30+
The request is missing the origin header.`
31+
);
32+
}
33+
2434
let forbidden = inputOrigin !== origin;
2535

2636
if (

packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.unit.ts

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe('csrf handler', () => {
1313
{
1414
contentType: 'text/plain',
1515
},
16-
])('should throw an error if the origin does not match for $contentType', ({ contentType }) => {
16+
])('should reject request when the origin does not match for $contentType', ({ contentType }) => {
1717
const errorFn = vi.fn();
1818
const requestEv = {
1919
request: {
@@ -26,13 +26,131 @@ describe('csrf handler', () => {
2626
error: errorFn,
2727
} as unknown as RequestEvent;
2828

29+
expect(() => csrfCheckMiddleware(requestEv)).toThrow();
30+
expect(errorFn).toBeCalledWith(403, expect.stringMatching('CSRF check failed'));
31+
});
32+
33+
it('should reject request when origin header is missing for form content types', () => {
34+
const errorFn = vi.fn();
35+
const requestEv = {
36+
request: {
37+
headers: new Headers({
38+
'content-type': 'application/x-www-form-urlencoded',
39+
// No origin header
40+
}),
41+
},
42+
url: new URL('http://example.com'),
43+
error: errorFn,
44+
} as unknown as RequestEvent;
45+
46+
expect(() => csrfCheckMiddleware(requestEv)).toThrow();
47+
expect(errorFn).toBeCalledWith(403, expect.stringMatching('CSRF check failed'));
48+
});
49+
50+
it.each([
51+
{
52+
contentType: 'application/x-www-form-urlencoded',
53+
},
54+
{
55+
contentType: 'multipart/form-data',
56+
},
57+
{
58+
contentType: 'text/plain',
59+
},
60+
])('should allow request when origin matches for $contentType', ({ contentType }) => {
61+
const errorFn = vi.fn();
62+
const requestEv = {
63+
request: {
64+
headers: new Headers({
65+
'content-type': contentType,
66+
origin: 'http://example.com',
67+
}),
68+
},
69+
url: new URL('http://example.com'),
70+
error: errorFn,
71+
} as unknown as RequestEvent;
72+
73+
// Should not throw an error
74+
expect(() => csrfCheckMiddleware(requestEv)).not.toThrow();
75+
expect(errorFn).not.toHaveBeenCalled();
76+
});
77+
78+
it.each([
79+
{
80+
contentType: 'application/json',
81+
},
82+
{
83+
contentType: 'text/html',
84+
},
85+
{
86+
contentType: 'application/xml',
87+
},
88+
{
89+
contentType: 'image/png',
90+
},
91+
])(
92+
'should allow request for non-form content type $contentType regardless of origin',
93+
({ contentType }) => {
94+
const errorFn = vi.fn();
95+
const requestEv = {
96+
request: {
97+
headers: new Headers({
98+
'content-type': contentType,
99+
origin: 'http://example.com',
100+
}),
101+
},
102+
url: new URL('http://bad-example.com'),
103+
error: errorFn,
104+
} as unknown as RequestEvent;
105+
106+
// Should not throw an error for non-form content types
107+
expect(() => csrfCheckMiddleware(requestEv)).not.toThrow();
108+
expect(errorFn).not.toHaveBeenCalled();
109+
}
110+
);
111+
112+
it('should allow request when content-type header is missing', () => {
113+
const errorFn = vi.fn();
114+
const requestEv = {
115+
request: {
116+
headers: new Headers({
117+
origin: 'http://example.com',
118+
// No content-type header
119+
}),
120+
},
121+
url: new URL('http://example.com'),
122+
error: errorFn,
123+
} as unknown as RequestEvent;
124+
125+
// Should not throw an error when content-type is missing
126+
expect(() => csrfCheckMiddleware(requestEv)).not.toThrow();
127+
expect(errorFn).not.toHaveBeenCalled();
128+
});
129+
130+
it('should verify exact error message content', () => {
131+
const errorFn = vi.fn();
132+
const requestEv = {
133+
request: {
134+
headers: new Headers({
135+
'content-type': 'application/x-www-form-urlencoded',
136+
origin: 'http://malicious.com',
137+
}),
138+
},
139+
url: new URL('http://example.com'),
140+
method: 'POST',
141+
error: errorFn,
142+
} as unknown as RequestEvent;
143+
29144
try {
30145
csrfCheckMiddleware(requestEv);
31146
} catch (_) {
32147
// ignore the error here, we just want to check the errorFn
33148
}
34149

35-
expect(errorFn).toBeCalledWith(403, expect.stringMatching('CSRF check failed'));
150+
expect(errorFn).toBeCalledWith(
151+
403,
152+
'CSRF check failed. Cross-site POST form submissions are forbidden.\nThe request origin "http://malicious.com" does not match the server origin "http://example.com".'
153+
);
36154
});
37155

38156
describe('isContentType', () => {
@@ -43,5 +161,53 @@ describe('csrf handler', () => {
43161
});
44162
expect(isContentType(headers, 'multipart/form-data')).toBe(true);
45163
});
164+
165+
it('should handle multiple content type parameters', () => {
166+
const headers = new Headers({
167+
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
168+
});
169+
expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(true);
170+
});
171+
172+
it('should handle case insensitive content types', () => {
173+
const headers = new Headers({
174+
'content-type': 'APPLICATION/X-WWW-FORM-URLENCODED',
175+
});
176+
expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false);
177+
});
178+
179+
it('should return false for non-matching content types', () => {
180+
const headers = new Headers({
181+
'content-type': 'application/json',
182+
});
183+
expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false);
184+
});
185+
186+
it('should handle empty content-type header', () => {
187+
const headers = new Headers({});
188+
expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false);
189+
});
190+
191+
it('should handle missing content-type header', () => {
192+
const headers = new Headers({
193+
'other-header': 'value',
194+
});
195+
expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false);
196+
});
197+
198+
it('should handle multiple content type checks', () => {
199+
const headers = new Headers({
200+
'content-type': 'text/plain',
201+
});
202+
expect(isContentType(headers, 'application/x-www-form-urlencoded', 'text/plain')).toBe(true);
203+
expect(isContentType(headers, 'application/json', 'multipart/form-data')).toBe(false);
204+
});
205+
206+
it('should handle content type with only whitespace', () => {
207+
const headers = new Headers({
208+
'content-type': ' ',
209+
});
210+
expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false);
211+
});
46212
});
47213
});

0 commit comments

Comments
 (0)