freealberta/influence/CSRF_FIX_SUMMARY.md
admin 4d8b9effd0 feat(blog): add detailed update on Influence and Map app developments since August
A bunch of udpates to the listmonk sync to add influence to it
2025-10-25 12:45:35 -06:00

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`