A GraphQL server parses any POST body as JSON whatever the Content-Type says. How is that forged?
answer
- A form has only three encodings
- One of them barely encodes anything
- The equals sign lands inside a string
- Uploads reopen what strictness closed
- A header no form can set
basics
~20 sA plain HTML form can send a cross-origin body as text/plain, with cookies attached and no permission step. Split across a field name and value, that body is valid JSON, so a lenient server executes it.
solid answer
~50 sA form can send exactly three body encodings, and a browser dispatches all three cross-origin without asking the destination server for permission. One of them, plain text, serialises each field as `name=value`, so an attacker puts the JSON document up to an opening quote in the field name and the closing brace in the value; the `=` the browser inserts lands harmlessly inside a string literal, and the bytes on the wire are a valid GraphQL request. A server that ignores the declared media type parses it and runs the mutation with the victim's cookie attached. The same reasoning covers an endpoint that legitimately accepts multipart bodies for uploads: that media type is one a form can produce, so strictness alone does not cover the upload path. The fixes are to refuse any media type other than `application/json` before parsing, and to require a non-safelisted custom header on the upload path, since a page cannot set one.
code
http · 7 linesPOST /graphql HTTP/1.1
Host: claims.example.com
Origin: https://unrelated-forum.example
Content-Type: text/plain
Cookie: claims_session=7c1f9ab2e4d05531
{"query":"mutation{approveClaim(claimId:\"CLM-40318\"){status}}","pad":"="}go deeper
Recall the headline: a plain HTML form can send a body as plain text, and a server that ignores the declared media type will happily parse that body as a GraphQL request.
Be able to explain the field-name-and-value trick that makes the form's body valid JSON, and why refusing every media type except application/json before parsing is what closes it.
Show review instincts: find the half-open checks — downstream of the parser, an empty-header exemption, body sniffing, a separately mounted upload route — and know how to confirm the weakness with a harmless read.
Own the remediation order and its rationale: one gate all routes pass, a non-safelisted header on the upload path, then reducing what a forged request is worth by moving off ambient credentials. Decide who may relax a body parser.
## Why plain text is a body an attacker can send An HTML form can serialise its fields in exactly three ways: the URL-encoded form encoding, `multipart/form-data`, and `text/plain`. All three are media types a browser will dispatch to another origin without first asking that origin for permission, and all three carry the victim's cookies. That is the raw material. The only question left is whether a body in one of those encodings can be made to look like a GraphQL request — and with `text/plain` it can, because that encoding is almost transparent: each field is written as its name, an `=`, its value, and a line break. So the attacker writes one field whose **name** is everything in the JSON document up to an opening quote, and whose **value** is the closing quote and brace. The browser joins them with the one character the attacker does not control, and that character lands inside a string literal where it does no harm: ```html <form action="https://claims.example.com/graphql" method="POST" enctype="text/plain"> <input name='{"query":"mutation{approveClaim(claimId:\"CLM-40318\"){status}}","pad":"' value='"}'> </form> <script>document.forms[0].submit()</script> ``` On the wire the body is `{"query":"mutation{...}","pad":"="}` followed by a line break, which every JSON parser accepts as whitespace. A server that reads the body without consulting `Content-Type` sees a perfectly ordinary request, executes the mutation against a session it never authenticated for this page, and returns a response the attacker cannot read and does not need. The attack needs no script beyond auto-submitting the form, no permission from your server, and no knowledge of your schema beyond one mutation name and its arguments. ## The second hole: an upload path you added on purpose The plain-text trick is defeated by refusing every media type but `application/json`. The upload path is harder, because there the safelisted media type is one you accept deliberately. The GraphQL multipart request convention carries an operation and its file bytes in a `multipart/form-data` body — a media type a plain form can produce, and one a browser dispatches without asking. An endpoint that accepts multipart bodies has, by design, an entry point that a page can reach. The standard mitigation is to require a **non-safelisted request header** on that path — any header a form cannot set. Requiring one forces the browser to ask the destination for permission before sending anything, which restores exactly the property that `application/json` gave the ordinary path. The header's name does not matter and there is no standardised one; what matters is that it is a header no HTML form can produce, and that the server refuses the multipart request when it is absent. Do not confuse this with checking a header's *value*: the security property is the browser's refusal to dispatch, not the contents. ## The half-open check, which is what you actually find in review In practice the vulnerability is rarely a missing check. It is a check that was written and then softened. The patterns to look for, in the order they show up: - **The check is downstream of the parser.** The framework reads and deserialises the body, and the media type is inspected in application code afterwards. The bytes have already been trusted. - **The check has an escape clause.** `application/json` or an empty `Content-Type` — added because some caller sent nothing — reopens the path, since a form can omit the header just as easily. - **The check reads the body instead of the header.** "If it starts with a brace, it is JSON." That is not a media-type check. - **`text/plain` was allowed for a reason nobody remembers.** A tool, a health probe, a legacy widget. It is the exact media type the attack needs. - **The upload path bypasses the shared gate.** The JSON route is strict and the multipart route was mounted separately, with its own body reader and no header requirement. ## Confirming it, and what to do next Confirming is quick and does not require an exploit page: replay a real read as `Content-Type: text/plain` with an otherwise identical body and see whether it executes. If the server answers with data rather than a 4xx, the media type is decorative. Do this with a read, not a write — the point is the acceptance, not the effect. The remediation order is worth stating in an interview because it shows you know which controls are load-bearing. First, make the media-type check the single gate every route passes through, in front of the body reader, matching the parsed media type and refusing an absent header. Second, require a non-safelisted header on the multipart route. Third, reduce what a forged request would be worth: cookie attributes that govern cross-site sending, and, better, a session credential the browser does not attach on its own. Adding a forgery token to a cookie-authenticated endpoint is reasonable, but it is the fourth item, not the first — it rejects a request that already arrived, whereas the media-type gate stops it being sent. One closing precision that separates a strong answer: none of this is defined by the GraphQL specification, and none of it by the GraphQL over HTTP working draft as an anti-forgery rule. The draft says JSON bodies are labelled `application/json` and that GET carries queries only. The forgery consequences are what follows when you combine those rules with what a browser will and will not dispatch, and a candidate who attributes them to "the spec" is guessing.
- Why does requiring a custom header protect the multipart upload path when a media-type check cannot?Because the media type is one you accept on purpose, so strictness has nothing to reject. A custom header is different in kind: a page causing a cross-origin request cannot add one, and asking for it forces the browser to seek the destination's permission before sending anything. The security comes from the browser's refusal to dispatch, not from the header's value — checking its contents adds nothing.
- How would you confirm this weakness on a running endpoint without writing an exploit page?Replay a known-good read with the body unchanged and the header set to `text/plain`. If the response carries data rather than a 4xx, the declared media type is not being enforced. Use a read rather than a write so the probe cannot change state, and check the upload route separately — it is frequently mounted with its own body reader and its own rules.
- Would allowing an empty Content-Type header for legacy callers be an acceptable compromise?No. A form can omit the header as easily as it can set `text/plain`, so an empty-header exemption restores the whole attack. If a legacy caller cannot set the header, fix or fence that caller — route it through an internal path with its own authentication, or fix the client — rather than widening the public gate for everyone.
- Does this weakness matter if the endpoint is authenticated by a token in a header rather than a cookie?Much less. Forgery needs a credential the browser attaches on its own; a header-borne token is not attached to a request a page merely causes, so the forged request arrives unauthenticated. Enforce the media type anyway — it is cheap and it protects you the day someone adds a cookie session for a browser client.
saying these in an interview costs you the question
- Assumes a form cannot produce a body that parses as JSON
- Thinks a browser blocks all cross-origin POSTs outright
- Allows an empty Content-Type header for legacy callers
- Decides the media type by sniffing the body bytes
- Leaves the upload route outside the shared gate
- Validates the custom header's value instead of its presence