const crypto = require('crypto'); // Mock crypto-utils to provide a predictable signing key const mockFixedKey = Buffer.alloc(32, 'test-key-material'); jest.mock('../crypto-utils', () => ({ loadOrCreateKey: jest.fn(() => mockFixedKey), })); const { CSRF_TOKEN_LENGTH, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generateToken, signToken, parseCookie, csrfCookieMiddleware, csrfValidationMiddleware, renewCSRFToken } = require('../csrf-protection'); const { createMockReqRes } = require('./helpers/test-utils'); describe('CSRF Protection', () => { describe('generateToken', () => { it('returns a base64url-encoded string', () => { const token = generateToken(); expect(typeof token).toBe('string'); expect(token.length).toBeGreaterThan(0); // base64url chars only expect(token).toMatch(/^[A-Za-z0-9_-]+$/); }); it('returns different values on each call', () => { const t1 = generateToken(); const t2 = generateToken(); expect(t1).not.toBe(t2); }); it('has appropriate length for 32 bytes of randomness', () => { const token = generateToken(); // 32 bytes = 43 base64url chars (no padding) expect(token.length).toBe(43); }); }); describe('signToken', () => { it('returns a base64url-encoded HMAC signature', () => { const sig = signToken('test-nonce'); expect(typeof sig).toBe('string'); expect(sig).toMatch(/^[A-Za-z0-9_-]+$/); }); it('same nonce produces same signature (deterministic)', () => { const sig1 = signToken('my-nonce'); const sig2 = signToken('my-nonce'); expect(sig1).toBe(sig2); }); it('different nonces produce different signatures', () => { const sig1 = signToken('nonce-a'); const sig2 = signToken('nonce-b'); expect(sig1).not.toBe(sig2); }); }); describe('parseCookie', () => { it('parses single cookie', () => { expect(parseCookie('name=value')).toEqual({ name: 'value' }); }); it('parses multiple cookies', () => { const result = parseCookie('a=1; b=2; c=3'); expect(result).toEqual({ a: '1', b: '2', c: '3' }); }); it('handles cookies with = in value', () => { const result = parseCookie('token=abc=def=ghi'); expect(result.token).toBe('abc=def=ghi'); }); it('returns empty object for null/undefined/empty input', () => { expect(parseCookie(null)).toEqual({}); expect(parseCookie(undefined)).toEqual({}); expect(parseCookie('')).toEqual({}); }); it('trims outer whitespace of each cookie pair', () => { const result = parseCookie(' name=value '); expect(result['name']).toBe('value'); }); }); describe('csrfCookieMiddleware', () => { it('generates new nonce and sets cookie when no existing cookie', () => { const { req, res, next } = createMockReqRes(); req.headers.cookie = ''; csrfCookieMiddleware(req, res, next); expect(req.csrfNonce).toBeDefined(); expect(req.csrfToken).toBeDefined(); expect(res.cookie).toHaveBeenCalledWith( CSRF_COOKIE_NAME, req.csrfNonce, expect.objectContaining({ httpOnly: false, sameSite: 'strict', path: '/', }) ); expect(next).toHaveBeenCalled(); }); it('reuses existing nonce from cookie (no new Set-Cookie)', () => { const { req, res, next } = createMockReqRes(); const existingNonce = 'existing-nonce-value'; req.headers.cookie = `${CSRF_COOKIE_NAME}=${existingNonce}`; csrfCookieMiddleware(req, res, next); expect(req.csrfNonce).toBe(existingNonce); expect(res.cookie).not.toHaveBeenCalled(); // No new cookie set expect(next).toHaveBeenCalled(); }); it('sets req.csrfToken as HMAC signature of nonce', () => { const { req, res, next } = createMockReqRes(); req.headers.cookie = `${CSRF_COOKIE_NAME}=my-nonce`; csrfCookieMiddleware(req, res, next); const expectedSig = signToken('my-nonce'); expect(req.csrfToken).toBe(expectedSig); }); }); describe('csrfValidationMiddleware', () => { it('skips validation for GET requests', () => { const { req, res, next } = createMockReqRes({ method: 'GET' }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); }); it('skips validation for HEAD requests', () => { const { req, res, next } = createMockReqRes({ method: 'HEAD' }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); }); it('skips validation for OPTIONS requests', () => { const { req, res, next } = createMockReqRes({ method: 'OPTIONS' }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); }); it('skips validation in test environment', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'test'; const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services' }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); process.env.NODE_ENV = origEnv; }); it('skips validation for excluded paths', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const excludedPaths = ['/api/totp/verify', '/api/totp/setup', '/health', '/api/health']; for (const excludedPath of excludedPaths) { const { req, res, next } = createMockReqRes({ method: 'POST', path: excludedPath }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); } process.env.NODE_ENV = origEnv; }); it('skips validation for auth gate paths', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/auth/gate/plex' }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); process.env.NODE_ENV = origEnv; }); it('skips validation when x-api-key header present', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services', headers: { 'x-api-key': 'dk_abc_123' } }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); process.env.NODE_ENV = origEnv; }); it('skips validation when Authorization Bearer header present', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services', headers: { authorization: 'Bearer some-jwt-token' } }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); process.env.NODE_ENV = origEnv; }); it('returns 403 when CSRF cookie missing', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services', headers: { cookie: '' } }); csrfValidationMiddleware(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ error: expect.stringContaining('DC-100') }) ); process.env.NODE_ENV = origEnv; }); it('returns 403 when CSRF header missing', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const nonce = generateToken(); const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services', headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` } }); csrfValidationMiddleware(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ error: expect.stringContaining('DC-100') }) ); process.env.NODE_ENV = origEnv; }); it('returns 403 when signature is invalid', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const nonce = generateToken(); const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services', headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}`, 'x-csrf-token': 'totally-wrong-signature' } }); csrfValidationMiddleware(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ error: expect.stringContaining('DC-101') }) ); process.env.NODE_ENV = origEnv; }); it('passes when cookie nonce and header signature match', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const nonce = generateToken(); const signature = signToken(nonce); const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/services', headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}`, 'x-csrf-token': signature } }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); process.env.NODE_ENV = origEnv; }); it('normalizes /api/v1/ prefix for exclusion matching', () => { const origEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const { req, res, next } = createMockReqRes({ method: 'POST', path: '/api/v1/totp/verify' }); csrfValidationMiddleware(req, res, next); expect(next).toHaveBeenCalled(); process.env.NODE_ENV = origEnv; }); }); describe('renewCSRFToken', () => { it('generates new nonce and sets cookie', () => { const { res } = createMockReqRes(); const token = renewCSRFToken(res, true); expect(typeof token).toBe('string'); expect(res.cookie).toHaveBeenCalledWith( CSRF_COOKIE_NAME, expect.any(String), expect.objectContaining({ httpOnly: false, secure: true, sameSite: 'strict', path: '/', }) ); }); it('returns signed token', () => { const { res } = createMockReqRes(); const token = renewCSRFToken(res, false); // Get the nonce that was set in the cookie const setCookieNonce = res.cookie.mock.calls[0][1]; const expectedSig = signToken(setCookieNonce); expect(token).toBe(expectedSig); }); }); });