BillTracker/docs/CSRF-SPA-Setup.md

191 lines
6.8 KiB
Markdown
Raw Normal View History

2026-05-09 13:03:36 -05:00
# CSRF SPA Setup Guide
## Overview
This document describes the CSRF (Cross-Site Request Forgery) protection implementation for the Single Page Application (SPA) frontend.
## Problem Statement
### Symptoms
- SPA (Single Page Application) couldn't read the CSRF cookie
- Create user and other POST requests failed with: `"Your session has expired or this request may be fraudulent"`
- The CSRF cookie was set as HttpOnly by default, preventing JavaScript access
### Root Causes
1. The `csrfTokenProvider` middleware sets `res.locals.csrfToken` but the CSRF cookie wasn't being generated for SPA `index.html` requests
2. The `*` route serving `index.html` was bypassing CSRF cookie generation
3. HttpOnly cookies cannot be read by JavaScript, which prevented the SPA from extracting the token
## Solution
### Implementation in `server.js`
The fix ensures the CSRF cookie is set before sending the SPA `index.html`:
```javascript
const { getCsrfToken } = require('./middleware/csrf');
app.get('*', (req, res) => {
// Set CSRF cookie if not present (needed for SPA to read token)
getCsrfToken(req, res);
res.sendFile(path.join(DIST, 'index.html'));
});
```
### Environment Configuration
Add these environment variables to your `.env` file:
```bash
# CSRF Settings for SPA
CSRF_HTTP_ONLY=false # Allow JavaScript to read cookie (SPA mode)
CSRF_SAME_SITE=lax # Better SPA compatibility (vs 'strict')
CSRF_COOKIE_NAME=bt_csrf_token # Customize cookie name if needed
```
> **Note:** When `CSRF_HTTP_ONLY=false`, the cookie is accessible via JavaScript (`document.cookie`). This is required for SPA CSRF token extraction but should only be used when the application is served from the same origin.
## How It Works
### Double-Submit Pattern
The implementation uses the **double-submit cookie pattern**:
1. **Server** sets a CSRF cookie with a cryptographically secure token
2. **Client** reads the cookie via JavaScript and sends it in the `x-csrf-token` header for state-changing requests
3. **Server** validates that the header token matches the cookie token
### Flow Diagram
```
┌─────────────┐ ┌─────────────┐
│ Browser │ │ Server │
└──────┬──────┘ └──────┬──────┘
│ │
│ GET / │
│───────────────────────>│
│ │
│ │ Set CSRF cookie (httpOnly=false)
│ │ Set-Cookie: bt_csrf_token=<token>; Path=/; SameSite=lax
│ │
│ │ Send index.html
│ │ └─> getCsrfToken(req, res) called
│ │
│ index.html + JS │
<───────────────────────┘
│ Read cookie: document.cookie
│ Extract token from bt_csrf_token=<token>
│ POST /api/bills
│ x-csrf-token: <token>
│───────────────────────>
│ │
│ │ Validate: header token == cookie token
│ │ If match → process request
│ │ If mismatch → 403 Forbidden
│ │
│ 200 OK / 403 │
<───────────────────────┘
```
### Code Flow
1. **Request enters `server.js`**
- `csrfTokenProvider` middleware runs on every request (via `app.use(csrfTokenProvider)`)
- This ensures `res.locals.csrfToken` is always available
2. **SPA route `app.get('*')` is hit**
- Explicitly calls `getCsrfToken(req, res)` before sending `index.html`
- This guarantees the CSRF cookie is set even for the initial SPA load
3. **Frontend reads the cookie**
```javascript
// Example from frontend
function getCsrfToken() {
const match = document.cookie.match(/bt_csrf_token=([^;]+)/);
return match ? match[1] : null;
}
```
4. **API calls include the token**
```javascript
const token = getCsrfToken();
fetch('/api/bills', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': token,
},
body: JSON.stringify(data),
});
```
5. **Server validates**
- `csrfMiddleware` extracts the token from:
- `x-csrf-token` header (preferred for API)
- `csrf_token` query parameter (form submissions)
- `csrf_token` body field (form submissions)
- Compares against the cookie token
- Returns 403 if validation fails
## Security Considerations
### HttpOnly Setting
- **Default (Secure):** `CSRF_HTTP_ONLY=true` — Cookie is NOT accessible via JavaScript, only sent automatically with requests
- **SPA Mode:** `CSRF_HTTP_ONLY=false` — Cookie IS accessible via JavaScript, enabling the double-submit pattern
### SameSite Attribute
- `lax` (recommended for SPA): Allows cookie to be sent with top-level navigations but blocks cross-site POST requests
- `strict`: Most secure but may break SPA functionality across domains
- `none`: Requires `Secure=true` (HTTPS only)
### Cookie Name
Default: `bt_csrf_token`
Customize via `CSRF_COOKIE_NAME` if running multiple applications on the same domain.
## Troubleshooting
### Issue: "CSRF token validation failed" on SPA
**Symptoms:**
- POST/PUT/DELETE/PATCH requests fail with 403
- Error message: "Your session has expired or this request may be fraudulent"
**Check:**
1. Verify `CSRF_HTTP_ONLY=false` is set
2. Check browser DevTools → Application → Cookies → Ensure `bt_csrf_token` cookie exists
3. Verify the frontend is sending `x-csrf-token` header with correct token value
### Issue: Cookie not being set
**Symptoms:**
- No `bt_csrf_token` cookie appears in browser DevTools
**Check:**
1. Verify `app.use(csrfTokenProvider)` is in `server.js`
2. Ensure `getCsrfToken(req, res)` is called in the SPA route handler
3. Check that cookies are not blocked by browser settings or extensions
### Issue: Token mismatch
**Symptoms:**
- Cookie exists but validation still fails
**Check:**
1. Clear browser cookies and refresh
2. Ensure only one CSRF cookie exists (no duplicate names)
3. Check server restart didn't generate a new cookie before the SPA read the old one
## References
- [`middleware/csrf.js`](../middleware/csrf.js) — Core CSRF implementation
- [`server.js`](../server.js) — Express server with SPA route
- [OWASP CSRF Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)