162 lines
5.8 KiB
Markdown
162 lines
5.8 KiB
Markdown
# 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**:
|
|
1. The API client (`api-client.js`) wasn't fetching or sending CSRF tokens at all
|
|
2. The retry logic didn't have a guard against infinite recursion
|
|
3. FormData wasn't including the CSRF token
|
|
|
|
**Solutions**:
|
|
1. **Added CSRF token management** to the API client:
|
|
- `fetchCsrfToken()` - Fetches token from `/api/csrf-token` endpoint
|
|
- `ensureCsrfToken()` - Ensures a valid token exists before requests
|
|
- Tokens are automatically included in state-changing requests (POST, PUT, PATCH, DELETE)
|
|
|
|
2. **Fixed infinite retry loop**:
|
|
- Added `isRetry` parameter to `makeRequest()`, `postFormData()`, and `putFormData()`
|
|
- Retry only happens once per request
|
|
- If second attempt fails, error is thrown to the user
|
|
|
|
3. **Enhanced token handling**:
|
|
- JSON requests: Token sent via `X-CSRF-Token` header
|
|
- FormData requests: Token sent via `_csrf` field
|
|
- Token automatically refreshed if server responds with new token
|
|
|
|
4. **Server-side updates**:
|
|
- Added explicit CSRF protection to `/api/csrf-token` endpoint so it can generate tokens
|
|
- Exported `csrfProtection` middleware for explicit use
|
|
|
|
## Files Modified
|
|
|
|
### 1. `app/middleware/csrf.js`
|
|
```javascript
|
|
// 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`
|
|
```javascript
|
|
// 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 include `X-CSRF-Token` header
|
|
- Modified `postFormData()` and `putFormData()` to include `_csrf` field
|
|
- 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
|
|
|
|
- [x] 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
|
|
|
|
1. **CSRF Attack Prevention**: Malicious sites can't forge requests to your app
|
|
2. **Session Hijacking Protection**: httpOnly, secure, sameSite cookies
|
|
3. **Defense in Depth**: Multiple security layers (Helmet, rate limiting, CSRF, validation)
|
|
4. **Automatic Token Rotation**: Tokens refresh on each response when available
|
|
5. **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:**
|
|
|
|
1. Check browser console for detailed error messages
|
|
2. Clear browser cookies and session storage
|
|
3. Logout and login again to get a fresh session
|
|
4. Verify the session hasn't expired (1 hour timeout)
|
|
5. Check server logs for CSRF validation failures
|
|
|
|
**If infinite retry loop occurs:**
|
|
|
|
1. Check that `isRetry` parameter is being passed correctly
|
|
2. Verify FormData isn't being reused across retries
|
|
3. Clear the API client's cached token: `window.apiClient.csrfToken = null`
|