0165 — A safelisted body is accepted only from an allowed origin
Decision
An endpoint may declare safelistedBody: true. It then accepts its JSON body
under text/plain as well as application/json — same parser, same input
schema, same maxJsonBodyBytes. The server honours that declaration only
for a request whose Origin header names a site on its explicit cors.origin
allow-list. A wildcard is not an allow-list, null is not a site, and a
missing header is not an answer: each is refused with 403 before the text is
parsed. application/json is untouched by the check. The flag is POST-only
and transport-neutral.
Why the default refuses text/plain
parseJsonBody has always required application/json, and the comment beside
it says why: a text/plain body is a simple request. The browser sends a
simple request immediately — with cookies — and consults CORS only about
whether the page may read the response. A form on any site can therefore
post a text/plain body to a cookie-authenticated endpoint, and the server has
already acted by the time the browser says no. Requiring application/json
forces a preflight, and the preflight is where a foreign site is stopped.
Why an endpoint needs the exception
A preflight is a round trip, and a document that is being unloaded does not get
one. navigator.sendBeacon(url, blob) with type: 'application/json' and
fetch(url, { keepalive: true }) with a JSON Content-Type both report
success and both die on the preflight when url is another origin — measured
by a consumer on a page-leave event: the calls were made, the server received
nothing. The same beacon with a string body (text/plain;charset=UTF-8) is a
simple request and arrives every time.
Three consuming projects had this shape on 2026-09-06: all three serve the
frontend and the API from different origins, and all three send their
page-leave event with a JSON body. One worked around the contract with a raw route; the other two send a
body that, by this mechanism, does not arrive on unload. The framework’s own
guide already shows a beaconUrl built from a contract — the URL was
supported, the body was not.
Why the exception is gated on Origin, and on nothing weaker
Once an endpoint accepts text/plain, the CSRF wall above has a door in it,
and two of the three consumers keep their session cookie at SameSite=None,
so the browser would send it through that door from any site. Something on the
server has to close it, and Origin is the right thing:
- a browser sets
Originon every cross-originPOSTand onsendBeacon, and a page cannot choose or suppress it — it is the one header a foreign site cannot forge; - the server already has the list of sites it trusts:
cors.origin.
Three answers that look like an allow-list are not one, and each is refused:
'*'.assertCorsConfigforbids'*'withcredentials: true, but cookie authentication does not depend oncredentials— a server withorigin: '*', nocredentials, and a session cookie is legal and common. A wildcard admits every site, which is exactly the thing the check exists to prevent.null. A sandboxed iframe, a cross-origin redirect and afile://page all send the literalOrigin: null. It names no site; listing it would let any of those three in.- absent. A simple request from a browser always carries the header; a request without it did not come from the code path this flag serves.
resolveOrigin, which builds the CORS response headers, is not reused: for a
single-string origin it returns the configured value without comparing it to
the request, which is correct for a header and wrong for a gate. The gate is
its own function, isOriginAllowed, and compares in both shapes.
What the check is, and is not
The Origin check is a browser invariant, not authentication. A curl with
a stolen cookie and a forged Origin header passes it — and that is an
authenticated call by whoever holds the cookie, not a cross-site forgery. The
check removes the one attack a browser makes possible on the user’s behalf; it
adds nothing to what a cookie already means.
Two further consequences an application has to know:
- Identity carried in the body is not available to
authorize. The auth hook runs before the body is read (create.ts:hooks.authorize, thenparseRequestPayloadInto). A client that cannot send headers on unload —sendBeaconsends none, so a bearer-authenticated app must put its token in the body — reads that token inbeforeHandleor in the handler, not in an auth hook. Cookie-authenticated apps are unaffected: the cookie is a header. POSTonly.GETandHEADcarry no JSON body, multipart is its own parser, andPUT,PATCHandDELETEare never simple requests — they always preflight — so the flag would buy nothing there and only widen the surface the check guards.defineContractrefuses the other seven shapes.
Why the flag does not force expose: ['HTTP']
rawBody and rawResponse coerce an endpoint to HTTP-only because what they
change (retained text, an owned Response) has no meaning on a tool transport.
safelistedBody changes how an HTTP body is parsed; the operation’s input,
output and meaning are unchanged, and a beacon-receiving endpoint is no less a
tool than its neighbour. The flag rides through to MethodDef for hooks, into
the OpenAPI document as a second requestBody media type with the same schema,
and into the surface manifest’s operation fingerprint beside rawBody — the
fingerprint’s only job is to refuse two definitions of one operation that
disagree, and this is a way to disagree.
Consequence
EndpointDef.safelistedBody?: true;MethodDef.safelistedBody;assertSafelistedBodyEndpointat contract time.isOriginAllowed(cors, origin)in the CORS middleware;parseJsonBodyconsults it for atext/plainbody on a flagged endpoint and refuses with403 FORBIDDENnaming the reason.- A consumer with a raw route for its beacon can delete it; a consumer sending a JSON blob on unload can switch to a string body and receive it.
Related
- ADR 0051 — the JSON body limit this path shares.
- ADR 0038 — why
rawResponseis HTTP-only, and why this flag is not.