548aca6bee
Build and Push Docker Image / build (push) Successful in 51s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m48s
CI / Security audit (push) Successful in 2m27s
CI / Swagger Validation & Coverage (push) Successful in 2m27s
CI / Tests & coverage (push) Successful in 2m39s
- Fix permanent qBittorrent fallback degradation by resetting fallback flags during group-by polling (resolves #29) - Fix Ombi webhook key mismatch in status endpoint and test mocks (resolves #30) - Prevent timingSafeEqual TypeErrors on multi-byte CSRF token length mismatches (resolves #31) - Eliminate duplicate write stream on server.log by delegating to index.js console override (resolves #32)
95 lines
3.1 KiB
JavaScript
95 lines
3.1 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
/**
|
|
* Tests for server/middleware/verifyCsrf.js
|
|
*
|
|
* CSRF protection via the double-submit cookie pattern. These tests verify
|
|
* that the timing-safe comparison works correctly and that safe HTTP methods
|
|
* are correctly exempted.
|
|
*/
|
|
|
|
import verifyCsrf from '../../server/middleware/verifyCsrf.js';
|
|
|
|
function makeReq(method, cookieToken, headerToken) {
|
|
return {
|
|
method,
|
|
cookies: { csrf_token: cookieToken },
|
|
headers: { 'x-csrf-token': headerToken }
|
|
};
|
|
}
|
|
|
|
function makeRes() {
|
|
const res = {
|
|
statusCode: null,
|
|
body: null,
|
|
status(code) { this.statusCode = code; return this; },
|
|
json(body) { this.body = body; return this; }
|
|
};
|
|
return res;
|
|
}
|
|
|
|
describe('verifyCsrf middleware', () => {
|
|
describe('safe methods are exempted', () => {
|
|
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
|
|
it(`allows ${method} with no CSRF token`, () => {
|
|
const next = vi.fn();
|
|
verifyCsrf(makeReq(method, undefined, undefined), makeRes(), next);
|
|
expect(next).toHaveBeenCalledOnce();
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('mutating methods require valid token', () => {
|
|
const TOKEN = 'a'.repeat(64); // 64 hex chars = 32 bytes
|
|
|
|
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
|
it(`allows ${method} with matching tokens`, () => {
|
|
const next = vi.fn();
|
|
const res = makeRes();
|
|
verifyCsrf(makeReq(method, TOKEN, TOKEN), res, next);
|
|
expect(next).toHaveBeenCalledOnce();
|
|
expect(res.statusCode).toBeNull();
|
|
});
|
|
|
|
it(`blocks ${method} with mismatched tokens`, () => {
|
|
const next = vi.fn();
|
|
const res = makeRes();
|
|
verifyCsrf(makeReq(method, TOKEN, TOKEN.replace('a', 'b')), res, next);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it(`blocks ${method} with missing cookie token`, () => {
|
|
const next = vi.fn();
|
|
const res = makeRes();
|
|
verifyCsrf(makeReq(method, undefined, TOKEN), res, next);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body.error).toBe('CSRF token missing');
|
|
});
|
|
|
|
it(`blocks ${method} with missing header token`, () => {
|
|
const next = vi.fn();
|
|
const res = makeRes();
|
|
verifyCsrf(makeReq(method, TOKEN, undefined), res, next);
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
}
|
|
|
|
it('blocks when tokens have different lengths (timing-safe path)', () => {
|
|
const next = vi.fn();
|
|
const res = makeRes();
|
|
verifyCsrf(makeReq('POST', 'short', 'much-longer-token-here'), res, next);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body.error).toBe('CSRF token invalid');
|
|
});
|
|
|
|
it('blocks when tokens have same character length but different byte lengths (multi-byte)', () => {
|
|
const next = vi.fn();
|
|
const res = makeRes();
|
|
verifyCsrf(makeReq('POST', 'cafe\u0301', 'cafes'), res, next);
|
|
expect(res.statusCode).toBe(403);
|
|
expect(res.body.error).toBe('CSRF token invalid');
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|