Skip to content

fix(agent-bff): reject non-json content types with 415 - #1856

Merged
Tonours merged 8 commits into
mainfrom
fix/prd-1099-json-content-type
Sep 2, 2026
Merged

fix(agent-bff): reject non-json content types with 415#1856
Tonours merged 8 commits into
mainfrom
fix/prd-1099-json-content-type

Conversation

@Tonours

@Tonours Tonours commented Aug 28, 2026

Copy link
Copy Markdown
Member

What

Agent routes (POST/PUT/PATCH under /agent) answer 415 unsupported_media_type when the body cannot be read as JSON:

  • a non-JSON Content-Type, form-urlencoded included;
  • no Content-Type at all on a request that carries a body.

application/json and application/*+json parse. A bodyless POST needs no Content-Type.

fixes PRD-1099

Why

@koa/bodyparser skips a content type that matches no enabled type, without an error. The body became {}, so /User/list ran unfiltered and answered 200 — a wrong result set with a success status. fetch(url, { method: 'POST', body: JSON.stringify(...) }) sends text/plain;charset=UTF-8 when 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 the type-is the parser itself calls — instead of matching the header by hand. Hand-matching left a hole: koa's request.type is a bare header.split(';')[0], while the parser goes through content-type.parse, which throws on a parameter with no value. So application/json; charset and application/json;; looked like application/json to 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:

const JSON_BODY_TYPES = ['application/json', 'application/*+json'];

That matters. @koa/bodyparser only knows six fixed JSON types, so a guard promising application/*+json would have let application/ld+json through to the silent drop this PR is closing. Passing the list explicitly is also deliberate: extendTypes is merged with lodash merge, which merges arrays index by index, so { json: ['application/*+json'] } alone would overwrite application/json at index 0 — and type-is does not match a bare application/json against the wildcard. Every ordinary JSON body would have drawn a 415.

Going through type-is also handles the parameter forms for free: application/json; charset=utf-8 and the legal-but-odd application/json ; charset=utf-8 both parse, where a split(';')[0] comparison rejected the second on a trailing space.

Breaking

  • Form-urlencoded on agent routes used to parse as JSON with string values. It now gets 415. The document only ever declared application/json.
  • A request carrying a body with no Content-Type used 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 > 0 or a Transfer-Encoding header before it looks at the type.

/oauth/token keeps its form and JSON bodies — it is outside the agent scope.

How to test

  • yarn workspace @forestadmin/agent-bff test
  • POST /agent/v1/<collection>/list with Content-Type: text/plain and a filter body: 415. Same body as application/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

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

@linear-code

linear-code Bot commented Aug 28, 2026

Copy link
Copy Markdown

PRD-1099

@qltysh

qltysh Bot commented Aug 28, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (2)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent-bff/src/http/bff-http-error.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-bff/src/cli-core.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@Tonours
Tonours force-pushed the fix/prd-1099-json-content-type branch from cdb7a69 to 9f90e7e Compare August 31, 2026 09:11
Comment thread packages/agent-bff/src/cli-core.ts Outdated

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/agent-bff/src/cli-core.ts Outdated
Comment thread packages/agent-bff/src/cli-core.ts Outdated
Comment thread packages/agent-bff/src/cli-core.ts Outdated
Comment thread packages/agent-bff/test/cli-core.test.ts Outdated
Comment thread packages/agent-bff/src/openapi/openapi-document.ts Outdated
Comment thread packages/agent-bff/src/openapi/openapi-document.ts
Comment thread packages/agent-bff/src/openapi/openapi-document.ts
@Tonours

Tonours commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

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 request.type is a bare header.split(';')[0], while @koa/bodyparser goes through type-iscontent-type.parse, which throws on a parameter with no value and is swallowed into a false. So:

Content-Type: application/json; charset      guard: "application/json" -> pass
                                             parser: content-type.parse throws -> {} 

Body dropped, /list runs unfiltered, 200. Same for application/json;;. Measured against the installed packages, not reasoned about.

Fixed by asking the same question the parser asks: ctx.is(JSON_BODY_TYPES), koa's wrapper over that same type-is. The two can no longer disagree in the dangerous direction. Three cases added to the tests.

Bodyless POSTs are no longer rejected on their declared type. The previous shape checked the type first, so Content-Length: 0 with text/plain — what .NET's StringContent("") sends — went from a legitimate 200 to a 415. It was the only newly-415 request whose old behaviour was correct. The guard now checks that a body exists before it looks at the type: nothing to parse, nothing to drop, nothing to reject.

One asymmetry left, in the safe direction. The parser also accepts application/csp-report, application/reports+json and application/scim+json from its own defaults; the guard rejects them, since the document only ever promised application/json and application/*+json. Guard stricter than parser costs a clear 415; the reverse is what produced this whole ticket.

Also from the pass: the it.each rows for vnd.api+json and the two charset forms pass on revert — the parser already handled those. Only the ld+json row proves the extendTypes change. Left as regression guards rather than removed, since they pin the published contract, but worth knowing they are not what proves this push.

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/agent-bff/test/cli-core.test.ts Outdated
Comment on lines +54 to +60
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)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that hasAgentEdge is gone, if (!hasAiQueryRoute) return parseBody; fits again and skips building the second parser.

@nbouliol nbouliol left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Tonours
Tonours merged commit 8952273 into main Sep 2, 2026
32 of 33 checks passed
@Tonours
Tonours deleted the fix/prd-1099-json-content-type branch September 2, 2026 15:59
Tonours added a commit that referenced this pull request Sep 2, 2026
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>
Tonours added a commit that referenced this pull request Sep 2, 2026
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.
forest-bot added a commit that referenced this pull request Sep 2, 2026
## @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))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants