DEV Community

Yuriy Safronnynov
Yuriy Safronnynov

Posted on

CSRF. The Bug That Looks Like Legitimate Traffic

Your admin panel processes a state-changing request. The user is authenticated. The session cookie is valid. The server accepts it. The attacker wins.

This is not a dramatic exploit. It is the server doing its job, faithfully executing a request it was never supposed to accept, because nobody thought to ask where the request actually came from.


Vulnerable Path, CSRF Attack Flow, Secure Path

TL;DR

  • CSRF lets an attacker forge authenticated requests from a victim's browser. The server sees a valid session and executes the action.
  • CVE-2024-1538 exposed over 1 million WordPress sites via missing nonce validation. CVSS 8.8. Patched in File Manager 7.2.5.
  • Happy-path tests never catch this. You have to explicitly test the rejection path: missing token, invalid token, no state mutation.
  • Three layers of prevention: synchronizer tokens at middleware level, SameSite cookie attributes, Origin header validation.
  • AI-generated test suites create false confidence here. They test the correct flow, not the forged one.

What it is

Cross-Site Request Forgery tricks an authenticated user's browser into sending a request to your application on behalf of an attacker. The browser does what browsers do: it automatically attaches session cookies to every request. The server sees a valid cookie and processes the request. The user never saw the form that submitted it.

The technical condition that makes this possible: HTTP cookies are sent automatically on every matching request regardless of origin. If your server does not verify that a state-changing request came from your own application, any page on the web can forge one.

Developers introduce this without malice. They build the happy path, test the happy path, and ship the happy path. Cross-origin validation is not something most developers think about while building a file rename feature. In code review, reviewers look for logic errors, but never for the presence of CSRF tokens on every mutating endpoint.

The attack surface is the assumption that authenticated means authorized-to-have-submitted-this-form.


Real world damage

CVE-2024-1538 · WordPress File Manager · March 2024 · CVSS 8.8

In March 2024, a CSRF vulnerability was disclosed in the WordPress File Manager plugin, active on over 1 million websites. The flaw stemmed from missing nonce validation on the wp_file_manager page when handling the lang parameter.

An attacker could trick an authenticated administrator into clicking a crafted link, which would include a local JavaScript file into the application and open the door to remote code execution.

No credentials required. No brute force. Only a logged-in admin who
clicked something.

Source: NVD CVE-2024-1538,
published March 21, 2024. Affected all versions up to 7.2.4.
Patched in 7.2.5. Credit: Wordfence for responsible disclosure.

A QA engineer running cross-origin state-change tests before 7.2.5
would have caught this. The test is not complex: submit a mutating
request with a missing or incorrect nonce and assert the server rejects it. That test did not exist. The vulnerability shipped to a million sites.


The invisible bug problem

Happy-path tests authenticate as a real user and send requests exactly as the application intends. That is the problem.
CSRF is not about whether the endpoint works correctly for a legitimate request. It is about whether the server validates that the request is legitimate at all.

Your test suite exercises the authenticated user path and passes. The attack exploits the same path from an external origin and also passes, from the server's perspective.

The bug only surfaces when the test asks a different question:

Does this endpoint accept a state-changing request that arrives
without a valid CSRF token?

Standard test suites never ask that. They test that the endpoint
responds correctly. They do not test that the endpoint refuses incorrectly.


How QA engineers catch it

The core principle: every state-changing endpoint must reject requests that arrive without a valid CSRF token. Your suite needs to verify the rejection path, not just the success path.

PyTest

import pytest
import requests

BASE_URL = "https://your-app.com"

@pytest.fixture
def auth_session():
    session = requests.Session()
    session.post(f"{BASE_URL}/login", json={
        "username": "admin",
        "password": "test_password"
    })
    return session

def test_csrf_empty_token_returns_403(auth_session):
    # CVE-2024-1538 pattern: missing nonce = server must reject
    response = auth_session.post(
        f"{BASE_URL}/admin/file/rename",
        json={"old_name": "file.txt", "new_name": "hacked.txt"},
        headers={"X-CSRF-Token": ""}
    )
    assert response.status_code == 403

def test_csrf_forged_token_returns_403(auth_session):
    # attacker-controlled token value must be rejected
    response = auth_session.post(
        f"{BASE_URL}/admin/file/rename",
        json={"old_name": "file.txt", "new_name": "hacked.txt"},
        headers={"X-CSRF-Token": "attacker-forged-token-abc123"}
    )
    assert response.status_code == 403

def test_csrf_no_state_mutation(auth_session):
    # 403 is necessary but not sufficient — assert state did not change
    auth_session.post(
        f"{BASE_URL}/admin/file/rename",
        json={"old_name": "file.txt", "new_name": "hacked.txt"},
        headers={"X-CSRF-Token": ""}
    )
    response = auth_session.get(f"{BASE_URL}/admin/files")
    files = response.json()["files"]

    assert "hacked.txt" not in files
    assert "file.txt" in files
Enter fullscreen mode Exit fullscreen mode

A 403 status is necessary but not sufficient. If the server returns 403 but still processes the action, you have a different kind of bug.
Assert the state, not just the status.

Robot Framework

*** Settings ***
Library    RequestsLibrary

*** Variables ***
${BASE_URL}    https://your-app.com

*** Test Cases ***
CSRF Empty Token On File Rename Returns 403
    # CVE-2024-1538: missing nonce on file manager endpoint
    Create Session    app    ${BASE_URL}
    ${headers}=    Create Dictionary    X-CSRF-Token=${EMPTY}
    ${response}=    POST On Session    app    /admin/file/rename
    ...    json={"old_name": "file.txt", "new_name": "hacked.txt"}
    ...    headers=${headers}
    Should Be Equal As Strings    ${response.status_code}    403

CSRF Forged Token On File Rename Returns 403
    Create Session    app    ${BASE_URL}
    ${headers}=    Create Dictionary
    ...    X-CSRF-Token=attacker-forged-token-abc123
    ${response}=    POST On Session    app    /admin/file/rename
    ...    json={"old_name": "file.txt", "new_name": "hacked.txt"}
    ...    headers=${headers}
    Should Be Equal As Strings    ${response.status_code}    403

CSRF Missing Token On Lang Parameter Returns 403
    # mirrors exact CVE-2024-1538 attack surface:
    # lang parameter on wp_file_manager with no nonce validation
    Create Session    app    ${BASE_URL}
    ${headers}=    Create Dictionary    X-CSRF-Token=${EMPTY}
    ${response}=    POST On Session    app    /admin/file-manager
    ...    json={"lang": "../../../malicious.js"}
    ...    headers=${headers}
    Should Be Equal As Strings    ${response.status_code}    403

CSRF No State Mutation When Token Missing
    Create Session    app    ${BASE_URL}
    ${headers}=    Create Dictionary    X-CSRF-Token=${EMPTY}
    POST On Session    app    /admin/file/rename
    ...    json={"old_name": "file.txt", "new_name": "hacked.txt"}
    ...    headers=${headers}
    ${files_response}=    GET On Session    app    /admin/files
    Should Not Contain    ${files_response.json()["files"]}    hacked.txt
    Should Contain        ${files_response.json()["files"]}    file.txt
Enter fullscreen mode Exit fullscreen mode

TypeScript — Playwright API testing

import { test, expect, APIRequestContext } from '@playwright/test';

let apiContext: APIRequestContext;

test.beforeAll(async ({ playwright }) => {
  apiContext = await playwright.request.newContext({
    baseURL: 'https://your-app.com',
  });

  // authenticate once, reuse session across all CSRF tests
  await apiContext.post('/login', {
    data: { username: 'admin', password: 'test_password' }
  });
});

test.afterAll(async () => {
  await apiContext.dispose();
});

test('CSRF — empty token on file rename returns 403', async () => {
  // CVE-2024-1538 pattern: missing nonce = server must reject
  const response = await apiContext.post('/admin/file/rename', {
    data: { old_name: 'file.txt', new_name: 'hacked.txt' },
    headers: { 'X-CSRF-Token': '' }
  });

  expect(response.status()).toBe(403);
});

test('CSRF — forged token on file rename returns 403', async () => {
  // attacker-controlled token value must be rejected
  const response = await apiContext.post('/admin/file/rename', {
    data: { old_name: 'file.txt', new_name: 'hacked.txt' },
    headers: { 'X-CSRF-Token': 'attacker-forged-token-abc123' }
  });

  expect(response.status()).toBe(403);
});

test('CSRF — missing token on lang parameter returns 403', async () => {
  // mirrors exact CVE-2024-1538 attack surface:
  // lang parameter on wp_file_manager page, no nonce validation
  const response = await apiContext.post('/admin/file-manager', {
    data: { lang: '../../../malicious.js' },
  });

  expect(response.status()).toBe(403);
});

test('CSRF — no state mutation when token is missing', async () => {
  await apiContext.post('/admin/file/rename', {
    data: { old_name: 'file.txt', new_name: 'hacked.txt' },
    headers: { 'X-CSRF-Token': '' }
  });

  // state must be unchanged — file must not have been renamed
  const files = await apiContext.get('/admin/files');
  const body = await files.json();

  expect(body.files).not.toContain('hacked.txt');
  expect(body.files).toContain('file.txt');
});

test('CSRF — valid token on file rename succeeds', async () => {
  // confirm the protection does not block legitimate requests
  const tokenResponse = await apiContext.get('/csrf-token');
  const { token } = await tokenResponse.json();

  const response = await apiContext.post('/admin/file/rename', {
    data: { old_name: 'file.txt', new_name: 'renamed.txt' },
    headers: { 'X-CSRF-Token': token }
  });

  expect(response.status()).toBe(200);
});
Enter fullscreen mode Exit fullscreen mode

Run CSRF tests in isolation:

npx playwright test --grep "CSRF"
Enter fullscreen mode Exit fullscreen mode

playwright.config.ts for CI:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests/security',
  use: {
    baseURL: process.env.APP_URL || 'https://your-app.com',
  },
  projects: [
    {
      name: 'csrf-security',
      testMatch: '**/csrf.spec.ts',
    }
  ]
});
Enter fullscreen mode Exit fullscreen mode

CI/CD gate

These tests belong in GitLab CI as a blocking job on every merge
request
— not an optional security scan that runs weekly.

csrf-security-tests:
  stage: test
  script:
    - pytest tests/security/test_csrf.py -v
    - npx playwright test --grep "CSRF"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  allow_failure: false
Enter fullscreen mode Exit fullscreen mode

A 200 response on a missing-token request is an unambiguous pipeline failure condition. Pair with a Semgrep static analysis job that scans for route handlers missing CSRF middleware — the static check catches the pattern before deployment, the runtime tests confirm enforcement in the running application.

Burp Suite

Before writing a single automated test, use Burp Suite to map your
attack surface. Intercept a legitimate authenticated request to an admin endpoint, strip the CSRF token from the headers, and replay it. If it returns 200, you have a confirmed finding. Ten minutes per endpoint tells you where the gaps are before you spend time automating.

Environment note

Running CSRF tests locally is not the same as running them against your full stack. Some CDN edge configurations strip custom headers, including your CSRF token, before the request reaches the application server. A request that correctly returns 403 locally may return 200 in staging because the CDN silently dropped the token. Always run your CSRF suite against an environment that mirrors the full production network path.

🔬 Practice this yourself: yuriysafron.com/qa-sandbox/csrf


Why AI fails here

When a team asks GitHub Copilot to generate CSRF tests, what they get is a test that logs in, submits a form with a valid token, and asserts a 200 response. The test passes. The team sees green and believes they have CSRF coverage.

They have a happy-path test with a CSRF token in it. That is not the same thing.

The structural reason LLMs cannot reason about this vulnerability class: CSRF is a relationship between origins, not a property of a single request.
LLMs generate tests in isolation. They model what a correct request looks like. They do not model what a forged request from an attacker-controlled page looks like.

The concrete failure: a team ships a new admin file management module. All AI-generated tests pass. A researcher finds CVE-2024-1538 class behavior three weeks after release. The rename endpoint accepts requests with no token at all. The AI-generated suite never submitted a request without a token. The bug was always there. The suite never asked the question that would have found it.


How to prevent

1. Synchronizer token pattern — implement at the middleware level across every state-changing endpoint. Framework-level enforcement means indvidual developers cannot accidentally skip validation on new endpoints. If each developer must remember to add the check manually, someone will forget. Put it in the middleware.

2. SameSite cookie attributesSameSite=Strict or SameSite=Lax instructs the browser not to send cookies on cross-origin requests. Defense-in-depth, not a replacement for token validation. Older clients and certain redirect flows can bypass SameSite=Lax.

3. Origin and Referer header validation — the server checks that the Origin header matches its own domain. If it does not match, reject the request. Reliable secondary control when combined with the other two.

Prevention only works if your test suite actively verifies each of these controls is enforced. A token pattern that is implemented but never tested is a token pattern that someone will quietly remove in a refactor and you will not know until production.


Conclusion

Working on a cybersecurity platform protecting U.S. critical
infrastructure and multiple branches of the U.S. military gives you a specific perspective on what a missed test actually costs.

CSRF is not an academic edge case in that environment. It is the kind of gap that ends careers and makes headlines.

The tests that matter most are never the ones that verify the feature works. They are the ones that verify the system refuses to do the wrong thing. CSRF lives exactly in that gap and most teams have never written a test that looks for it.


Which endpoint in your current system would fail this test right now?


Part of the **Break It on Purpose* series - published weekly for QA engineers and SDETs who find bugs before attackers do.*

🔬 Practice sandbox: yuriysafron.com/qa-sandbox

🔗 linkedin.com/in/yuriysafronnynov

Top comments (1)

Collapse
 
safron profile image
Yuriy Safronnynov

Most CSRF test suites I've reviewed have the same blind spot, they test that the token works, never that the endpoint rejects a missing or forged one. The rejection path is where the vulnerability lives.
Curious how others are handling this in CI? Are you running these as blocking gates on every merge request, or still treating security tests as a separate audit cycle?