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

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:

  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

// 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 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

  • 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