fix(agent-bff): reject non-json content types with 415 - #1856
Conversation
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (2)
🛟 Help
|
cdb7a69 to
9f90e7e
Compare
nbouliol
left a comment
There was a problem hiding this comment.
Reviewed against PRD-1099. The 415 path itself is solid and the text/plain regression is covered. One blocking gap: the guard accepts a wider +json set than the parser can actually parse, so the original silent drop survives on those types.
|
Third push. The review pass found the guard still had a hole, and it was the ticket's own bug. The guard matched the header by hand; the parser does not. koa's Body dropped, Fixed by asking the same question the parser asks: Bodyless POSTs are no longer rejected on their declared type. The previous shape checked the type first, so One asymmetry left, in the safe direction. The parser also accepts Also from the pass: the |
nbouliol
left a comment
There was a problem hiding this comment.
Re-reviewed after the rework. The six earlier points are all addressed: ctx.is() plus extendTypes make the guard a subset of what the parser actually parses, so the silent drop is genuinely closed, and proving the parse through the size limit is a much stronger fixture than the old 401. One test does not send what its name claims, which leaves the new no-Content-Type branch uncovered.
| function hasBody(ctx: Parameters<Middleware>[0]): boolean { | ||
| return (ctx.request.length ?? 0) > 0 || ctx.get('transfer-encoding') !== ''; | ||
| } | ||
|
|
||
| function createJsonOnlyGuard(): Middleware { | ||
| return async function jsonOnlyGuard(ctx, next) { | ||
| if (BODY_METHODS.has(ctx.method) && hasBody(ctx) && !ctx.is(JSON_BODY_TYPES)) { |
There was a problem hiding this comment.
ctx.is() already returns null when there is no body, so ctx.is(JSON_BODY_TYPES) === false would drop hasBody entirely — the only behaviour change is a zero-length body with a non-JSON type, which would then 415 too.
| @@ -60,14 +78,16 @@ function agentScoped(middleware: Middleware): Middleware { | |||
| } | |||
|
|
|||
| function createBodyParser(hasAiQueryRoute: boolean): Middleware { | |||
There was a problem hiding this comment.
Now that hasAgentEdge is gone, if (!hasAiQueryRoute) return parseBody; fits again and skips building the second parser.
nbouliol
left a comment
There was a problem hiding this comment.
Three rounds in, the guard and the parser now agree on exactly one set, and the fixtures prove the parse instead of inferring it from a 401. The no-Content-Type body was closed rather than documented, which is the shape the ticket would have kept bleeding on. Remaining suggestion on hasBody is not blocking. The red ai-proxy check is a provider-side tool-call failure, unrelated to this diff.
Keep strict-body openapi tests from this branch, add page-optional and ai-query coverage from main, and align the 415 prose test with #1856. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep strict-body openapi tests from this branch, add page-optional and ai-query coverage from main, and align the 415 prose test with #1856.
## @forestadmin/agent-bff [1.23.6](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-bff@1.23.5...@forestadmin/agent-bff@1.23.6) (2026-09-02) ### Bug Fixes * **agent-bff:** document the default page size in the openapi document ([#1865](#1865)) ([7d67637](7d67637)) * **agent-bff:** reject non-json content types with 415 ([#1856](#1856)) ([8952273](8952273))

What
Agent routes (POST/PUT/PATCH under
/agent) answer415 unsupported_media_typewhen the body cannot be read as JSON:Content-Type, form-urlencoded included;Content-Typeat all on a request that carries a body.application/jsonandapplication/*+jsonparse. A bodyless POST needs noContent-Type.fixes PRD-1099
Why
@koa/bodyparserskips a content type that matches no enabled type, without an error. The body became{}, so/User/listran unfiltered and answered 200 — a wrong result set with a success status.fetch(url, { method: 'POST', body: JSON.stringify(...) })sendstext/plain;charset=UTF-8when you forget the header, so this was one missing line away in any browser client.The document declared 415 on 91 operations. It was unreachable.
How
One guard,
agentScoped(createJsonOnlyGuard()), mounted before the parser and gated on the agent chain being up — the same shape as the agent error middleware. It throws before any parsing, so the 415 fires before auth, next to the existing 413.The guard asks the same question the parser asks —
ctx.is(JSON_BODY_TYPES), which is koa's wrapper over thetype-isthe parser itself calls — instead of matching the header by hand. Hand-matching left a hole: koa'srequest.typeis a bareheader.split(';')[0], while the parser goes throughcontent-type.parse, which throws on a parameter with no value. Soapplication/json; charsetandapplication/json;;looked likeapplication/jsonto a hand-rolled guard, were skipped by the parser, and became{}— the original bug, on a header a client can produce by accident.Both sides read the same constant:
That matters.
@koa/bodyparseronly knows six fixed JSON types, so a guard promisingapplication/*+jsonwould have letapplication/ld+jsonthrough to the silent drop this PR is closing. Passing the list explicitly is also deliberate:extendTypesis merged with lodashmerge, which merges arrays index by index, so{ json: ['application/*+json'] }alone would overwriteapplication/jsonat index 0 — andtype-isdoes not match a bareapplication/jsonagainst the wildcard. Every ordinary JSON body would have drawn a 415.Going through
type-isalso handles the parameter forms for free:application/json; charset=utf-8and the legal-but-oddapplication/json ; charset=utf-8both parse, where asplit(';')[0]comparison rejected the second on a trailing space.Breaking
application/json.Content-Typeused to have that body dropped. It now gets 415, which is what RFC 9110 allows a recipient to do with a payload it cannot identify.A bodyless POST is never rejected, whatever it declares: with nothing to parse there is nothing to drop. The guard checks
Content-Length > 0or aTransfer-Encodingheader before it looks at the type./oauth/tokenkeeps its form and JSON bodies — it is outside the agent scope.How to test
yarn workspace @forestadmin/agent-bff test/agent/v1/<collection>/listwithContent-Type: text/plainand a filter body: 415. Same body asapplication/json: filtered rows.The parse-side tests send an oversized body and expect 413 rather than 401: the size limit only fires once the parser has read the body, so it is the cheapest proof that the content type reached a parser instead of being dropped.
Definition of Done
General
Security