tractatus/src/middleware/csrf-protection.middleware.js
TheFlow 6b79f9a155 fix(newsletter): resolve CSRF token issue for static HTML pages
Problem:
- nginx serves blog.html as static file, bypassing Express middleware
- setCsrfToken middleware never runs
- No CSRF cookie set
- Newsletter subscription fails with 403 Forbidden

Root cause:
nginx config: 'try_files $uri @proxy' serves static files directly
Location: /etc/nginx/sites-available/tractatus (line 54)

Solution:
1. blog.js now fetches CSRF token via /api/csrf-token on page load
2. getCsrfToken endpoint now creates token if missing (for static pages)
3. Newsletter form uses fetched token for subscription

Testing:
 Local test: CSRF token fetched successfully
 Newsletter subscription: Creates record in database
 Verified: test-fix@example.com subscribed via curl test

Impact:
- Newsletter subscriptions now work on production
- Fix applies to all static HTML pages (blog.html, etc.)
- Maintains CSRF protection security

Files:
- public/js/blog.js: Added fetchCsrfToken() + use in newsletter form
- src/middleware/csrf-protection.middleware.js: Enhanced getCsrfToken()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-25 09:37:16 +13:00

127 lines
3.5 KiB
JavaScript

/**
* CSRF Protection Middleware (Modern Approach)
*
* Uses SameSite cookies + double-submit cookie pattern
* Replaces deprecated csurf package
*
* Reference: OWASP CSRF Prevention Cheat Sheet
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
*/
const crypto = require('crypto');
const { logSecurityEvent, getClientIp } = require('../utils/security-logger');
/**
* Generate CSRF token
*/
function generateCsrfToken() {
return crypto.randomBytes(32).toString('hex');
}
/**
* CSRF Protection Middleware
*
* Uses double-submit cookie pattern:
* 1. Server sets CSRF token in secure, SameSite cookie
* 2. Client must send same token in custom header (X-CSRF-Token)
* 3. Server validates cookie matches header
*/
function csrfProtection(req, res, next) {
// Skip GET, HEAD, OPTIONS (safe methods)
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// Get CSRF token from cookie
const cookieToken = req.cookies['csrf-token'];
// Get CSRF token from header
const headerToken = req.headers['x-csrf-token'] || req.headers['csrf-token'];
// Validate tokens exist and match
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
logSecurityEvent({
type: 'csrf_violation',
sourceIp: getClientIp(req),
userId: req.user?.id,
endpoint: req.path,
userAgent: req.get('user-agent'),
details: {
method: req.method,
hasCookie: !!cookieToken,
hasHeader: !!headerToken,
tokensMatch: cookieToken === headerToken
},
action: 'blocked',
severity: 'high'
});
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid CSRF token',
code: 'CSRF_VALIDATION_FAILED'
});
}
next();
}
/**
* Middleware to set CSRF token cookie
* Apply this globally or on routes that need CSRF protection
*/
function setCsrfToken(req, res, next) {
// Only set cookie if it doesn't exist
if (!req.cookies['csrf-token']) {
const token = generateCsrfToken();
//Check if we're behind a proxy (X-Forwarded-Proto header)
const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
res.cookie('csrf-token', token, {
httpOnly: false, // Must be false for double-submit pattern (client needs to read it)
secure: isSecure && process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
}
next();
}
/**
* Endpoint to get CSRF token for client-side usage
* GET /api/csrf-token
*
* Returns the CSRF token from the cookie (or creates one if missing)
* This is required for pages served as static HTML by nginx (bypassing setCsrfToken middleware)
*/
function getCsrfToken(req, res) {
let token = req.cookies['csrf-token'];
// If no token exists, create one (for static HTML pages served by nginx)
if (!token) {
token = generateCsrfToken();
// Check if we're behind a proxy (X-Forwarded-Proto header)
const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
res.cookie('csrf-token', token, {
httpOnly: false, // Must be false for double-submit pattern (client needs to read it)
secure: isSecure && process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
}
res.json({
csrfToken: token
});
}
module.exports = {
csrfProtection,
setCsrfToken,
getCsrfToken,
generateCsrfToken
};