Fix: parse JSON bodies only in the toA2a server (drop express.urlencoded) - #558
Merged
AmaadMartin merged 3 commits intoJul 31, 2026
Merged
Conversation
added 3 commits
July 28, 2026 23:04
`toA2a` mounted `express.urlencoded({extended: true})` on the app it
creates for itself. `application/x-www-form-urlencoded` is a
CORS-safelisted request content type, so a cross-origin POST carrying it
is a simple request: the browser sends it with no preflight and no
opt-in from the target server. With `extended: true` the body went
through `qs`, which rebuilds arbitrarily nested objects from flat keys,
so a hidden auto-submitting form on any page the victim visits could
hand-build a complete JSON-RPC request and drive the agent.
Verified against a real server: before this change a form-encoded POST
to /jsonrpc invoked the agent and returned a completed task; after it,
the request is rejected at the JSON-RPC envelope and the agent never
runs. Requiring `application/json` forces a preflight, which this app
does not answer, so the browser blocks the cross-origin write.
`express.json({limit: '50mb'})` is unchanged, and the `options.app`
branch is untouched: a caller supplying their own app still owns their
own middleware stack.
Simplifications from the complexity review:
- Replace the hand-rolled promisified `http.request` helper with global
`fetch`, matching how `dev/test/server/adk_api_server_test.ts` already
drives a locally-listening server.
- Drop the express-4-vs-5 body hedge. `core/package.json` pins
`express: ^4.22.1`, and a caret range on 4.x cannot resolve to 5.x, so
the `undefined` case it guarded is unreachable.
- Drop assertions strictly implied by a neighbouring one: the
`not.toHaveProperty('a')` follows from the deep-equal to `{}`, and the
unmounted-middleware check follows from the factory never being called.
- Inline the single-use payload constant and shrink the recorder JSDoc.
- Rename the file to `agent_to_a2a_body_parsing_test.ts`: it covers the
JSON positive control too, so the old name pointed at half of it.
Round-2 complexity review, both points backed by the code: - `restHandler` and `jsonRpcHandler` are mocked to the same recorder and `express.json()` is mounted app-wide with no path prefix, so the `/jsonrpc` case ran byte-identical middleware and handler to `/rest` and could not fail independently. One `it` covers the guarantee; the real `/jsonrpc` surface is exercised against the actual SDK in the manual E2E instead. - Move `post` inside `describe` to close over `port`: 11 core test files already declare their helpers this way and nothing in the repo's lint config or CONTRIBUTING.md argues otherwise. Also widen the sibling test's name, which still asserts agent-card, executor and handler wiring beyond body parsing.
Closed
7 tasks
kalenkevich
approved these changes
Jul 31, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
2. Or, if no issue exists, describe the change:
Problem:
When
toA2a()creates its own Express app (i.e. the caller does not passoptions.app), it mountsexpress.urlencoded({limit: '50mb', extended: true})alongsideexpress.json(). That makes the A2A surface reachable by a drive-by cross-origin formPOST:application/x-www-form-urlencodedis a CORS-safelisted request content type, so a cross-originPOSTcarrying it is a simple request: the browser sends it with no preflight and no opt-in from the target server. The attacker cannot read the response, but the side effects of the agent run — tool invocations, session mutations, artifact writes — still happen.extended: trueroutes the body throughqs, which reconstructs arbitrarily nested objects from flat keys (a[b][0][c]=d). That gives a form body enough expressiveness to hand-build a complete JSON-RPC request object.This was verified against a real server rather than assumed. With the parser mounted, a pure form-encoded
POSTto/jsonrpcinvoked the agent and returned a completed task:After the change the same request is rejected at the JSON-RPC envelope and the agent never runs:
Solution:
Drop the
express.urlencodedmount and keepexpress.json({limit: '50mb'})unchanged. Nothing in the A2A contract needs form bodies — the JSON-RPC transport isapplication/jsonby definition, theHTTP+JSON(REST) transport is likewise JSON, and the agent-card route is aGETwith no request body. With onlyexpress.json()mounted, a cross-originPOSTis no longer a simple request: the browser must first send a CORS preflight, andtoA2amounts no CORS policy, so the preflight goes unanswered and the real request is never issued.I chose removal over adding a CORS policy or a content-type-rejecting middleware because the preflight already blocks the cross-origin case; either addition would be extra surface area for no security gain. This is the same one-line fix already applied to the dev API server's identical mount in #378, which landed without a regression test — this PR adds one so the mount cannot come back silently.
A short comment is left in place of the deleted line explaining why the parser is absent, since the natural future "why can't I POST a form to my agent?" fix is to re-add it.
Behavior change (intentional, security-motivated): no public API, type, signature or export changes, and the set and order of mounted routes are identical. A caller who used the app returned by
toA2a()as a general-purpose Express app and mounted their own routes readingreq.bodyfrom form posts will no longer see a parsed body. The migration path is one line and already supported: create your ownexpress()app, add whatever middleware you need, and pass it asoptions.app— in which casetoA2ainstalls no parsers at all. Theoptions.appbranch is untouched by this PR. The only in-repo caller,dev/src/server/adk_api_server.ts, passesoptions.appand is therefore unaffected.Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
core/test/a2a/agent_to_a2a_test.tsmocksexpresswholesale, so it can only provetoA2anever callsexpress.urlencoded. Its two assertions that previously required the mount were inverted rather than deleted, keepingurlencodedon the mock so "never called" stays meaningful:core/test/a2a/agent_to_a2a_body_parsing_test.ts(new) leavesexpressreal and mocks only the A2A SDK handlers, so it asserts the actual behavior of the assembled middleware stack over a real loopback socket: a form-encodedPOSTcarrying the nestedqspayloada[b][0][c]=dreaches the handler with an emptyreq.body, while a JSONPOSTstill arrives fully parsed. That JSON positive control is what stops the test from also passing if the whole middleware stack were removed.Both guards were mutation-tested: with the
urlencodedmount restored, they fail (expected { a: { b: [ { c: 'd' } ] } } to deeply equal {}), confirming they actually detect a regression rather than passing vacuously.Whole A2A suite, lint and format on the exact pushed commit:
tsc --noEmitreports the same 308 pre-existing errors before and after this branch (they come fromcore/disttype duplication after a build and are unrelated to these files), so this change introduces none.No integration test was added: the guarantee is purely about the HTTP middleware stack, and the new unit test already runs a real Express app over a real socket, so a second slower copy of the same assertion would be redundant. No new dependency was added — the test uses global
fetch, matchingdev/test/server/adk_api_server_test.ts.Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
npm run buildat the repo root.{"code":-32600,"message":"Invalid JSON-RPC Request."}with the agent never invoked.GET /.well-known/agent-card.jsonalso still serves the card.adk api_server --a2aand confirm the dev server's A2A routes behave identically — it passesoptions.app, so nothing about it changes.Checklist
Additional context
None beyond the above.