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();
}