Skip to content

Fix: parse JSON bodies only in the toA2a server (drop express.urlencoded) - #558

Merged
AmaadMartin merged 3 commits into
google:mainfrom
AmaadMartin:fix/a2a-drop-urlencoded-body-parser
Jul 31, 2026
Merged

Fix: parse JSON bodies only in the toA2a server (drop express.urlencoded)#558
AmaadMartin merged 3 commits into
google:mainfrom
AmaadMartin:fix/a2a-drop-urlencoded-body-parser

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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 pass options.app), it mounts express.urlencoded({limit: '50mb', extended: true}) alongside express.json(). That makes the A2A surface reachable by a drive-by cross-origin form POST:

  • 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. The attacker cannot read the response, but the side effects of the agent run — tool invocations, session mutations, artifact writes — still happen.
  • extended: true routes the body through qs, 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 POST to /jsonrpc invoked the agent and returned a completed task:

$ # body: jsonrpc=2.0&id=1&method=message/send&params[message][messageId]=m1
$ #       &params[message][role]=user&params[message][kind]=message
$ #       &params[message][parts][0][kind]=text&params[message][parts][0][text]=pwned
!!! AGENT WAS INVOKED BY THE FORM POST !!!
HTTP 200 {"jsonrpc":"2.0","id":"1","result":{"kind":"task","id":"c695a1e0-...","history":[...]}}

After the change the same request is rejected at the JSON-RPC envelope and the agent never runs:

HTTP 200 {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid JSON-RPC Request."}}

Solution:

Drop the express.urlencoded mount and keep express.json({limit: '50mb'}) unchanged. Nothing in the A2A contract needs form bodies — the JSON-RPC transport is application/json by definition, the HTTP+JSON (REST) transport is likewise JSON, and the agent-card route is a GET with no request body. With only express.json() mounted, a cross-origin POST is no longer a simple request: the browser must first send a CORS preflight, and toA2a mounts 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 reading req.body from form posts will no longer see a parsed body. The migration path is one line and already supported: create your own express() app, add whatever middleware you need, and pass it as options.app — in which case toA2a installs no parsers at all. The options.app branch is untouched by this PR. The only in-repo caller, dev/src/server/adk_api_server.ts, passes options.app and 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:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

core/test/a2a/agent_to_a2a_test.ts mocks express wholesale, so it can only prove toA2a never calls express.urlencoded. Its two assertions that previously required the mount were inverted rather than deleted, keeping urlencoded on the mock so "never called" stays meaningful:

npx vitest run --project unit:core core/test/a2a/agent_to_a2a_test.ts
   ✓ 8 passed

core/test/a2a/agent_to_a2a_body_parsing_test.ts (new) leaves express real 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-encoded POST carrying the nested qs payload a[b][0][c]=d reaches the handler with an empty req.body, while a JSON POST still arrives fully parsed. That JSON positive control is what stops the test from also passing if the whole middleware stack were removed.

npx vitest run --project unit:core core/test/a2a/agent_to_a2a_body_parsing_test.ts
   ✓ 2 passed

Both guards were mutation-tested: with the urlencoded mount 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:

npx vitest run --project unit:core core/test/a2a/    → 14 files, 191 passed
npm run build                                        → success
npx eslint core/src/a2a/agent_to_a2a.ts core/test/a2a/agent_to_a2a_test.ts \
           core/test/a2a/agent_to_a2a_body_parsing_test.ts   → clean
npx prettier --check <same three files>              → clean

tsc --noEmit reports the same 308 pre-existing errors before and after this branch (they come from core/dist type 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, matching dev/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.

  1. npm run build at the repo root.
  2. In a scratch script, stand up a server the documented standalone way:
    const app = await toA2a(myAgent, {allowUnauthenticated: true});
    app.listen(8000);
  3. Send the attack request — a CORS-safelisted content type, so a browser would send this cross-origin with no preflight:
    curl -i -X POST http://localhost:8000/jsonrpc \
      -H 'Content-Type: application/x-www-form-urlencoded' \
      --data 'jsonrpc=2.0&id=1&method=message/send&params[message][messageId]=m1&params[message][role]=user&params[message][kind]=message&params[message][parts][0][kind]=text&params[message][parts][0][text]=pwned'
    Before this change the agent runs and a completed task is returned. After it, the body is never parsed and the response is {"code":-32600,"message":"Invalid JSON-RPC Request."} with the agent never invoked.
  4. Confirm the legitimate path is unchanged:
    curl -i -X POST http://localhost:8000/jsonrpc \
      -H 'Content-Type: application/json' \
      --data '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{}}'
    Still dispatched into the JSON-RPC method exactly as before. GET /.well-known/agent-card.json also still serves the card.
  5. Regression check on the untouched path: run adk api_server --a2a and confirm the dev server's A2A routes behave identically — it passes options.app, so nothing about it changes.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

None beyond the above.

Amaad Martin 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.
@AmaadMartin
AmaadMartin merged commit 605b469 into google:main Jul 31, 2026
13 checks passed
@kalenkevich kalenkevich mentioned this pull request Jul 31, 2026
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