Rework CSRF: deterministic token from session + self-healing cookie (no more stale 403s)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user