diff --git a/dashcaddy-api/__tests__/csrf-protection.test.js b/dashcaddy-api/__tests__/csrf-protection.test.js index b039f57..022e9ad 100644 --- a/dashcaddy-api/__tests__/csrf-protection.test.js +++ b/dashcaddy-api/__tests__/csrf-protection.test.js @@ -322,6 +322,89 @@ describe('CSRF Protection', () => { 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', () => { diff --git a/dashcaddy-api/src/security/csrf-protection.js b/dashcaddy-api/src/security/csrf-protection.js index 97cc7f4..ad7581f 100644 --- a/dashcaddy-api/src/security/csrf-protection.js +++ b/dashcaddy-api/src/security/csrf-protection.js @@ -214,14 +214,30 @@ function csrfValidationMiddleware(req, res, next) { return next(); } - // Validate both values exist + // DC-058: differentiate "browser auto-retry" from "real probe" using the + // X-CSRF-Token header as a signal. The dashboard JS in status/js/globals.js + // secureFetch() pre-fetches /api/v1/csrf-token (which sets the CSRF cookie + // via csrfCookieMiddleware) before posting; if the GET raced with container + // restart OR the user cleared cookies mid-session, the POST can arrive with + // a header but no cookie. secureFetch catches the 403 and auto-retries + // with a fresh token (lines 225-238 of globals.js). For these "has header + // but no cookie" misses, tag the log line [CSRF-debug] — operators can + // grep them out as expected noise. A request with NEITHER cookie NOR + // header (curl probe, exploit scanner, broken client) keeps the louder + // [CSRF] tag. if (!cookieNonce) { - process.stderr.write(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}\n`); + const isLikelyBrowserAutoRetry = !!headerToken; + const tag = isLikelyBrowserAutoRetry ? '[CSRF-debug]' : '[CSRF]'; + process.stderr.write(`${tag} Missing CSRF cookie: ${method} ${req.path} from ${req.ip}` + + (isLikelyBrowserAutoRetry ? ' (browser auto-retry — header present, expect self-heal)' : '') + '\n'); return errorResponse(res, 403, '[DC-100] CSRF token missing', { message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.' }); } + // Cookie present but no header — a real browser POST always sends both, so + // header-less is suspicious (curl probe with manual cookie, misconfigured + // client). Keep WARN level. if (!headerToken) { process.stderr.write(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}\n`); return errorResponse(res, 403, '[DC-100] CSRF token missing', {