Rework CSRF: deterministic token from session + self-healing cookie (no more stale 403s)

This commit is contained in:
Kühn
2026-08-31 11:26:00 +02:00
parent a05133f39d
commit ec2ed91621
3 changed files with 57 additions and 15 deletions

View File

@@ -20,13 +20,25 @@ function hashToken(token) {
const CSRF_COOKIE_NAME = 'workflow_csrf';
const CSRF_HEADER_NAME = 'x-csrf-token';
function setCSRFCookie(res) {
const csrfToken = crypto.randomBytes(32).toString('hex');
// CSRF token is derived deterministically from the session token hash (HMAC).
// Advantage: cookie and header can never drift apart (no more stale-token 403s),
// works across tabs, page reloads and re-logins. The cookie is self-healed by
// authMiddleware on every request if it is missing or out of sync.
const CSRF_SECRET = process.env.SESSION_SECRET || 'workflow-portal-csrf-v1';
function deriveCSRFToken(tokenHash) {
return crypto.createHmac('sha256', CSRF_SECRET).update(tokenHash).digest('hex');
}
function setCSRFCookie(res, tokenHash) {
// If a session token hash is provided, derive the CSRF token from it (deterministic).
// Otherwise (e.g. register, no session yet) fall back to a random token.
const csrfToken = tokenHash ? deriveCSRFToken(tokenHash) : crypto.randomBytes(32).toString('hex');
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // Must be readable by JS to send back in header
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24h
maxAge: 24 * 60 * 60 * 1000, // 24h (re-set by authMiddleware on every request)
path: '/',
});
return csrfToken;
@@ -40,9 +52,25 @@ function csrfMiddleware(req, res, next) {
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
const headerToken = req.headers[CSRF_HEADER_NAME];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
// Accept if header matches cookie (classic double-submit) OR if the header
// matches the token derived from the current session (self-healing path).
let valid = cookieToken && headerToken && cookieToken === headerToken;
if (!valid && req.tokenHash && headerToken) {
valid = headerToken === deriveCSRFToken(req.tokenHash);
}
if (!valid) {
return res.status(403).json({ error: 'CSRF-Token ungültig oder fehlend.' });
}
// Self-heal: ensure the cookie always carries the correct token
if (req.tokenHash && cookieToken !== deriveCSRFToken(req.tokenHash)) {
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(req.tokenHash), {
httpOnly: false,
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000,
path: '/',
});
}
next();
}
@@ -66,6 +94,17 @@ async function authMiddleware(req, res, next) {
req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, status: session.status, source: session.source, username: session.username };
req.tokenHash = tokenHash;
// Self-healing CSRF: on safe requests (GET/HEAD/OPTIONS), re-issue the CSRF cookie
// derived from the current session so it can never go stale or out of sync.
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
res.cookie(CSRF_COOKIE_NAME, deriveCSRFToken(tokenHash), {
httpOnly: false,
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000,
path: '/',
});
}
next();
}

View File

@@ -9,7 +9,7 @@ const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie } = require('../middleware/auth');
const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie, hashToken } = require('../middleware/auth');
const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync');
const { loginLimiter } = require('../middleware/rateLimit');
const { validate, registerSchema, loginSchema } = require('../middleware/validation');
@@ -61,7 +61,8 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(adRow.id, oldToken);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie
// P4: CSRF cookie derived from the new session token (deterministic, self-healing)
const csrfToken = setCSRFCookie(res, hashToken(rawToken));
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login');
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...adRow, csrfToken });
@@ -89,7 +90,8 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
const oldTokenAD = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(row.id, oldTokenAD);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie
// P4: CSRF cookie derived from the new session token (deterministic, self-healing)
const csrfToken = setCSRFCookie(res, hashToken(rawToken));
auditLog(row.id, 'login', 'user', row.id, 'AD login');
const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth)
@@ -123,7 +125,8 @@ router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
const oldTokenLocal = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(row.id, oldTokenLocal);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie
// P4: CSRF cookie derived from the new session token (deterministic, self-healing)
const csrfToken = setCSRFCookie(res, hashToken(rawToken));
auditLog(row.id, 'login', 'user', row.id, 'Local login');
const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth)

View File

@@ -5,22 +5,22 @@ export const FILE_BASE = import.meta.env.VITE_API_BASE
? import.meta.env.VITE_API_BASE.replace(/\/api$/, '')
: '';
// P5: CSRF token kept in memory only (not localStorage - not sensitive, but avoids stale tokens)
let csrfToken = null;
// Helper: Read CSRF token from cookie (fallback after page reload)
// P5: CSRF token — the server derives it deterministically from the session and
// self-heals the cookie on every GET. The frontend simply reads the cookie fresh
// on every request, so memory/cookie can never drift apart (no more 403s).
function getCSRFTokenFromCookie() {
const match = document.cookie.match(/workflow_csrf=([^;]+)/);
return match ? match[1] : null;
}
export function setCSRFToken(token) {
csrfToken = token;
// Kept for API compatibility; the cookie is the single source of truth now.
// No-op: token is always read fresh from the cookie.
}
export function getCSRFToken() {
// Return memory token, or fallback to cookie (after page reload)
return csrfToken || getCSRFTokenFromCookie();
// Always read fresh from the cookie (server keeps it in sync on every GET)
return getCSRFTokenFromCookie();
}
// P5: Auth relies on HttpOnly cookie only - no token in localStorage