Files
dashcaddy/dashcaddy-api/__tests__/csrf-protection.test.js
T
DashCaddy Loop 084672c41c fix(csrf): tag browser auto-retry as [CSRF-debug], keep [CSRF] for real probes (DC-058)
Background: every time dashcaddy-api restarts, the first POST from a
dashboard browser tab hits the missing-CSRF-cookie branch. The
status/js/globals.js secureFetch() wrapper catches the 403 and
auto-retries with a fresh token, so the WARN line is misleading noise.

Live evidence (DNS2, 2026-08-18 10:35:32Z container restart):
  [CSRF] Missing CSRF cookie: POST /api/v1/backups/schedule from 100.121.150.22
  [CSRF] Missing CSRF cookie: POST /api/v1/backups/schedule from 100.121.150.22
  [CSRF] Missing CSRF cookie: POST /api/v1/backups/schedule from 172.17.0.1

Fix: if X-CSRF-Token header is ALSO present, tag the log line [CSRF-debug]
(operator can grep it out as expected noise — the secureFetch retry will
self-heal). A request with NEITHER cookie NOR header (curl probe, exploit
scanner, broken client) keeps the [CSRF] tag.

Threat model: forging a header without the cookie just produces a
different 403 (Invalid CSRF token) — the timingSafeEqual check on lines
248-260 of csrf-protection.js is unchanged. This is a log-only fix.

Tests: 4 new in __tests__/csrf-protection.test.js under
'DC-058: browser-auto-retry vs real-probe log tagging'. Full suite
1982/1982 green on DNS2 worktree.

[self-grade=pending]
2026-08-18 04:15:48 -07:00

438 lines
15 KiB
JavaScript

const crypto = require('crypto');
// Mock crypto-utils to provide a predictable signing key
const mockFixedKey = Buffer.alloc(32, 'test-key-material');
jest.mock('../src/security/crypto-utils', () => ({
loadOrCreateKey: jest.fn(() => mockFixedKey),
}));
const {
CSRF_TOKEN_LENGTH,
CSRF_COOKIE_NAME,
CSRF_HEADER_NAME,
generateToken,
signToken,
parseCookie,
csrfCookieMiddleware,
csrfValidationMiddleware,
renewCSRFToken
} = require('../src/security/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';
// Mirrors src/security/csrf-protection.js excludedPaths. If you add
// a new entry there, add it here too — the test guards against the
// drift that previously kept /api/v1/health in the list long after
// the route itself was deleted.
const excludedPaths = [
'/api/v1/totp/verify',
'/api/v1/totp/verify-setup',
'/api/v1/totp/setup',
'/health',
'/health/live',
'/health/ready',
'/healthz',
'/readyz',
'/api/v1/system/update-notify',
];
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/v1/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('excludes /api/v1/ paths directly', () => {
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;
});
// DC-058: differentiate "browser auto-retry" from "real probe" by the
// presence of the X-CSRF-Token header. The 403 response is identical in
// both branches; only the stderr log tag changes.
describe('DC-058: browser-auto-retry vs real-probe log tagging', () => {
let stderrSpy;
let origEnv;
beforeEach(() => {
origEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
afterEach(() => {
process.env.NODE_ENV = origEnv;
stderrSpy.mockRestore();
});
it('tags missing-cookie with [CSRF-debug] when X-CSRF-Token header also present (browser auto-retry)', () => {
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
});
csrfValidationMiddleware(req, res, next);
// 403 response unchanged
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('DC-100') })
);
// Log tag is [CSRF-debug]
expect(stderrSpy).toHaveBeenCalled();
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF-debug]');
expect(lastWrite).toContain('browser auto-retry');
expect(lastWrite).not.toMatch(/^\[CSRF\][^-]/); // not bare [CSRF]
});
it('tags missing-cookie with [CSRF] when no X-CSRF-Token header present (real probe)', () => {
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: '' }
});
csrfValidationMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(stderrSpy).toHaveBeenCalled();
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF]');
expect(lastWrite).not.toContain('[CSRF-debug]');
expect(lastWrite).not.toContain('browser auto-retry');
});
it('keeps [CSRF] tag when cookie present but header missing (curl probe with manual cookie)', () => {
const nonce = generateToken();
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` }
});
csrfValidationMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(stderrSpy).toHaveBeenCalled();
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF]');
expect(lastWrite).not.toContain('[CSRF-debug]');
});
it('headerToken is read from x-csrf-token (lowercased by Express) — lowercase header triggers [CSRF-debug]', () => {
// Express/Node lowercases all incoming header keys, so production code
// only ever sees lowercase. We test the exact code path here.
const { req, res, next } = createMockReqRes({
method: 'POST', path: '/api/v1/backups/schedule',
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
});
csrfValidationMiddleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
expect(lastWrite).toContain('[CSRF-debug]');
});
});
});
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);
});
});
});