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]
438 lines
15 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|