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>
127 lines
3.5 KiB
JavaScript
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
|
|
};
|