5.8 KiB
CSRF Security Update - Fix Summary
Date: October 23, 2025
Issues Encountered
After implementing CSRF security updates, the application experienced two main issues:
1. Login Failed with "Invalid CSRF token"
Problem: The login endpoint required a CSRF token, but users couldn't get a token before logging in (chicken-and-egg problem).
Root Cause: The /api/auth/login endpoint was being protected by CSRF middleware, but there's no session yet during initial login.
Solution: Added /api/auth/login and /api/auth/session to the CSRF exempt routes list in app/middleware/csrf.js. Login endpoints use credentials (username/password) for authentication, so they don't need CSRF protection.
2. Campaign Creation Failed with Infinite Retry Loop
Problem: When creating campaigns, the app would get stuck in an infinite retry loop with repeated "CSRF token validation failed" errors.
Root Causes:
- The API client (
api-client.js) wasn't fetching or sending CSRF tokens at all - The retry logic didn't have a guard against infinite recursion
- FormData wasn't including the CSRF token
Solutions:
-
Added CSRF token management to the API client:
fetchCsrfToken()- Fetches token from/api/csrf-tokenendpointensureCsrfToken()- Ensures a valid token exists before requests- Tokens are automatically included in state-changing requests (POST, PUT, PATCH, DELETE)
-
Fixed infinite retry loop:
- Added
isRetryparameter tomakeRequest(),postFormData(), andputFormData() - Retry only happens once per request
- If second attempt fails, error is thrown to the user
- Added
-
Enhanced token handling:
- JSON requests: Token sent via
X-CSRF-Tokenheader - FormData requests: Token sent via
_csrffield - Token automatically refreshed if server responds with new token
- JSON requests: Token sent via
-
Server-side updates:
- Added explicit CSRF protection to
/api/csrf-tokenendpoint so it can generate tokens - Exported
csrfProtectionmiddleware for explicit use
- Added explicit CSRF protection to
Files Modified
1. app/middleware/csrf.js
// Added to exempt routes:
const csrfExemptRoutes = [
'/api/health',
'/api/metrics',
'/api/config',
'/api/auth/login', // ← NEW: Login uses credentials
'/api/auth/session', // ← NEW: Session check is read-only
'/api/representatives/postal/',
'/api/campaigns/public'
];
// Enhanced getCsrfToken with error handling
2. app/server.js
// Added csrfProtection to imports
const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf');
// Applied explicit CSRF protection to token endpoint
app.get('/api/csrf-token', csrfProtection, getCsrfToken);
3. app/public/js/api-client.js
- Added CSRF token caching and fetching logic
- Modified
makeRequest()to includeX-CSRF-Tokenheader - Modified
postFormData()andputFormData()to include_csrffield - Added retry logic with infinite loop protection (max 1 retry)
- Added automatic token refresh on 403 errors
How CSRF Protection Works Now
Flow for State-Changing Requests (POST, PUT, DELETE):
1. User Action (e.g., "Create Campaign")
↓
2. API Client checks if CSRF token exists
↓ (if no token)
3. Fetch token from GET /api/csrf-token
↓
4. Include token in request:
- Header: X-CSRF-Token (for JSON)
- FormData: _csrf (for file uploads)
↓
5. Server validates token matches session
↓
6a. Success → Process request
6b. Invalid Token → Return 403
↓ (on 403, if not a retry)
7. Clear token, fetch new one, retry ONCE
↓
8a. Success → Return data
8b. Still fails → Throw error to user
Protected vs Exempt Endpoints
Protected (requires CSRF token):
- ✅ POST
/api/admin/campaigns- Create campaign - ✅ PUT
/api/admin/campaigns/:id- Update campaign - ✅ POST
/api/emails/send- Send email - ✅ POST
/api/auth/logout- Logout - ✅ POST
/api/auth/change-password- Change password
Exempt (no CSRF required):
- ✅ GET (all GET requests are safe)
- ✅ POST
/api/auth/login- Uses credentials - ✅ GET
/api/auth/session- Read-only check - ✅ GET
/api/health- Public health check - ✅ GET
/api/metrics- Prometheus metrics
Testing Checklist
- Login as admin works
- Create new campaign works
- Update existing campaign works
- Delete campaign works
- Send email to representative works
- Logout works
- Password change works
- Public pages work without authentication
Security Benefits
- CSRF Attack Prevention: Malicious sites can't forge requests to your app
- Session Hijacking Protection: httpOnly, secure, sameSite cookies
- Defense in Depth: Multiple security layers (Helmet, rate limiting, CSRF, validation)
- Automatic Token Rotation: Tokens refresh on each response when available
- Retry Logic: Handles token expiration gracefully
Important Notes
- CSRF tokens are tied to sessions and expire with the session (1 hour)
- Tokens are stored in cookies (httpOnly, secure in production)
- The retry logic prevents infinite loops by limiting to 1 retry per request
- Login doesn't need CSRF because it uses credentials for authentication
- All state-changing operations (POST/PUT/DELETE) now require valid CSRF tokens
Troubleshooting
If you see "Invalid CSRF token" errors:
- Check browser console for detailed error messages
- Clear browser cookies and session storage
- Logout and login again to get a fresh session
- Verify the session hasn't expired (1 hour timeout)
- Check server logs for CSRF validation failures
If infinite retry loop occurs:
- Check that
isRetryparameter is being passed correctly - Verify FormData isn't being reused across retries
- Clear the API client's cached token:
window.apiClient.csrfToken = null