iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Automated Tests

Without tests, your CSRF defences quietly erode. Regression tests should cover every state-changing endpoint: missing token rejected, mismatched token rejected, valid token accepted, and CORS preflight from unknown origin denied. Bake them into CI so a junior dev removing “that weird header check” gets a red build.

Test patterns + Express/Django/Rails

EXAMPLE
// 1) The bare-minimum tests every CSRF defence needs
// • Missing token → 403
// • Token mismatch → 403
// • Token valid → 2xx
// • Token reuse after rotation → 403
// • Cross-origin OPTIONS without allowlist → no Access-Control-Allow-Origin
// • Login establishes fresh CSRF token (session fixation defence)

// 2) Express + supertest + Jest/Vitest
import request from 'supertest';
import { app } from '../src/server.js';

describe('CSRF', () => {
    let agent, csrf;

    beforeEach(async () => {
        agent = request.agent(app);          // persists cookies
        const r = await agent.get('/login');
        csrf = parseCsrfCookie(r.headers['set-cookie']);
        await agent.post('/login').send({ csrf, email: 'mara@example.com', password: 'pw' });
    });

    test('POST without CSRF header is rejected', async () => {
        const r = await agent.post('/api/profile').send({ name: 'X' });
        expect(r.status).toBe(403);
    });

    test('POST with mismatched header is rejected', async () => {
        const r = await agent.post('/api/profile').set('X-CSRF-Token', 'mismatch').send({ name: 'X' });
        expect(r.status).toBe(403);
    });

    test('POST with valid token succeeds', async () => {
        const r = await agent.post('/api/profile').set('X-CSRF-Token', csrf).send({ name: 'X' });
        expect(r.status).toBe(200);
    });

    test('Login rotates CSRF token (session fixation)', async () => {
        const fresh = request.agent(app);
        const before = parseCsrfCookie((await fresh.get('/login')).headers['set-cookie']);
        await fresh.post('/login').send({ csrf: before, email: 'mara@example.com', password: 'pw' });
        const after = parseCsrfCookie((await fresh.get('/me')).headers['set-cookie']);
        expect(after).not.toEqual(before);
    });

    test('CORS preflight from unknown origin is not approved', async () => {
        const r = await request(app)
            .options('/api/profile')
            .set('Origin', 'https://evil.example.com')
            .set('Access-Control-Request-Method', 'POST');
        expect(r.headers['access-control-allow-origin']).toBeUndefined();
    });
});

function parseCsrfCookie(setCookieHeader) {
    if (!setCookieHeader) return null;
    const arr = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader];
    for (const c of arr) {
        const m = c.match(/csrf-token=([^;]+)/);
        if (m) return decodeURIComponent(m[1]);
    }
    return null;
}

// 3) Pre-login CSRF
test('login form rejects request without pre-login CSRF', async () => {
    const r = await request(app).post('/login').send({ email: 'mara@example.com', password: 'pw' });
    expect(r.status).toBe(403);
});

// 4) Sensitive flows require recent re-auth
test('email change requires recent re-auth', async () => {
    // Login old enough that recent-auth window expired
    const r = await agent.post('/account/email-change')
        .set('X-CSRF-Token', csrf)
        .send({ newEmail: 'evil@example.com' });
    expect(r.status).toBe(401);
});

// 5) SameSite assertions
test('session cookie has SameSite=Lax + HttpOnly + Secure', async () => {
    const r = await request(app).get('/login');
    const sc = r.headers['set-cookie'] ?? [];
    const session = sc.find((c) => c.startsWith('session='));
    expect(session).toMatch(/HttpOnly/);
    expect(session).toMatch(/Secure/);
    expect(session).toMatch(/SameSite=Lax|SameSite=Strict/);
});

// 6) Django + pytest
# tests/test_csrf.py
import pytest
from django.test import Client
from django.urls import reverse

@pytest.mark.django_db
def test_post_without_csrf_rejected(client):
    response = client.post(reverse('profile_update'), {'name': 'X'})
    assert response.status_code == 403

@pytest.mark.django_db
def test_post_with_csrf_succeeds(client, django_user_model):
    user = django_user_model.objects.create_user('mara', password='pw')
    client.login(username='mara', password='pw')
    # CsrfViewMiddleware token rotation
    client = Client(enforce_csrf_checks=True)
    client.login(username='mara', password='pw')
    client.get('/')                           # set csrftoken cookie
    token = client.cookies['csrftoken'].value
    response = client.post(reverse('profile_update'), {'name': 'X'}, HTTP_X_CSRFTOKEN=token)
    assert response.status_code == 200

# 7) Rails + RSpec
require 'rails_helper'
RSpec.describe 'CSRF', type: :request do
    before(:each) { ActionController::Base.allow_forgery_protection = true }

    it 'rejects POST without authenticity_token' do
        post '/profile/update', params: { name: 'X' }
        expect(response).to have_http_status(:unprocessable_entity)
    end

    it 'accepts POST with valid token' do
        get '/' # establishes session
        post '/profile/update', params: { name: 'X', authenticity_token: form_authenticity_token }
        expect(response).to have_http_status(:ok)
    end
end

# 8) Playwright — end-to-end check
import { test, expect } from '@playwright/test';

test('CSRF blocks forged form submission', async ({ page, context }) => {
    await page.goto('https://app.example.com/login');
    await page.fill('#email', 'mara@example.com');
    await page.fill('#password', 'pw');
    await page.click('button[type="submit"]');
    await page.waitForURL('**/dashboard');

    // Open evil page in same browser context (sharing cookies)
    const evil = await context.newPage();
    await evil.setContent(`
        <form id="f" action="https://app.example.com/api/profile" method="POST">
            <input name="name" value="compromised">
        </form>
        <script>document.getElementById('f').submit();</script>
    `);
    const response = await evil.waitForResponse('**/api/profile');
    expect(response.status()).toBe(403);
});

# 9) Negative tests — make sure your defences haven't disabled too much
test('CORS preflight from allowlisted origin IS approved', async () => {
    const r = await request(app)
        .options('/api/profile')
        .set('Origin', 'https://app.example.com')
        .set('Access-Control-Request-Method', 'POST');
    expect(r.headers['access-control-allow-origin']).toBe('https://app.example.com');
    expect(r.headers['access-control-allow-credentials']).toBe('true');
});

# 10) Snapshot tests for security headers
test('responses ship the security header set', async () => {
    const r = await request(app).get('/');
    expect(r.headers).toMatchObject({
        'strict-transport-security':  expect.stringMatching(/max-age=/),
        'x-content-type-options':     'nosniff',
        'referrer-policy':            expect.any(String),
        'permissions-policy':         expect.any(String),
        'content-security-policy':    expect.stringContaining('default-src'),
    });
});

# 11) Property-based tests
import fc from 'fast-check';

test('any random non-matching token is rejected', async () => {
    await fc.assert(fc.asyncProperty(
        fc.string({ minLength: 1, maxLength: 100 }),
        async (token) => {
            const r = await agent.post('/api/profile').set('X-CSRF-Token', token).send({ name: 'X' });
            if (token === csrf) return true;     // skip the rare valid hit
            expect(r.status).toBe(403);
            return true;
        },
    ), { numRuns: 100 });
});

# 12) Cross-cutting: every new endpoint should be auto-tested
# • A loop over all routes asserting state-changing ones require CSRF
for (const path of allWriteRoutes) {
    test(`${path} requires CSRF`, async () => {
        const r = await agent.post(path).send({});
        expect(r.status).toBe(403);
    });
}

# 13) CI guardrails
# • Run CSRF test suite on every PR
# • Block merges if any test fails
# • Run a synthetic check in production every 5 min and alert on drift

# 14) Common bugs
# • Tests use 'request(app)' instead of 'request.agent(app)' — cookies don't persist
# • Mocking the CSRF middleware in tests → tests never exercise the real defence
# • Forgetting to reload the CSRF token after login (rotation) → test passes against stale value
# • Assertions like 'not 200' — too loose; assert 403 specifically
# • CORS test passes locally (no Origin) but fails in CI behind a proxy
# • Forgetting double-submit cookie reads vs writes — test both directions
# • Not testing OPTIONS preflight — CORS misconfigs hide here
# • Disabling forgery protection in test config 'for convenience' — defeats the entire test

Why it matters

Every state-changing endpoint needs CSRF tests: missing token = 403, valid token = 2xx, mismatched origin not approved by CORS, login rotates the token. Wire them into CI, snapshot the security header set, and add a route-loop test so every new endpoint is automatically covered.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
test('rejects cross-origin POST without CSRF token', async () => {
    const res = await request(app).post('/transfer')
        .set('Cookie', session)
        .set('Origin', 'https://evil.example')
        .send({ to: 'attacker', amount: 1 });
    expect(res.status).toBe(403);
});
Try it Yourself »

Exercise

Status expected for a missing CSRF token.

expect(res.status).toBe( );

Discussion

Loading…