fix(server): validate push-notification URLs before dispatch (SSRF hardening) - #1164
Conversation
…rdening) A client sets its push-notification webhook URL via tasks/pushNotificationConfig (or inline on message/send), and the server then POSTs task events to that URL. The URL was used exactly as supplied - no scheme check, no destination check - so every deployment of the reference sender exposed a blind server-side request forgery primitive: point a task's push config at http://169.254.169.254/... (cloud metadata), http://localhost:PORT/admin, or any internal service and the agent server POSTs there on every task event. BasePushNotificationSender now validates each URL at dispatch time: scheme must be http/https, the host must resolve, and every resolved address must be public unicast (loopback, link-local, private, reserved, multicast, and unspecified addresses are rejected; unresolvable hosts fail closed since the POST would fail anyway). Operators whose legitimate webhooks live on private networks can opt out with allow_private_push_urls=True. Validation happens at dispatch rather than at config-write so configs registered through any path (create, inline on send, future stores) are covered by the same choke point. Residual risk, documented in the constructor docstring: DNS rebinding between validation and the POST itself remains possible for attacker-controlled domains; static internal targets are fully blocked. Tests: 7 new unit tests (metadata IP, loopback, private range, non-http scheme, unresolvable host fail-closed, public allowed, opt-out); existing suites made DNS-hermetic; push-notification e2e app opts out since its webhooks are real local servers. Signed-off-by: SashaMIT <sash@ela.city> Co-authored-by: Cursor <cursoragent@cursor.com>
🧪 Code Coverage (vs
|
| Base | PR | Delta | |
|---|---|---|---|
| src/a2a/server/request_handlers/default_request_handler.py | 97.90% | 97.90% | 🔴 -0.01% |
| src/a2a/server/request_handlers/default_request_handler_v2.py | 92.05% | 92.44% | 🟢 +0.39% |
| src/a2a/server/tasks/base_push_notification_sender.py | 81.43% | 95.12% | 🟢 +13.69% |
| src/a2a/utils/push_url_validator.py (new) | — | 87.80% | — |
| Total | 92.97% | 93.05% | 🟢 +0.09% |
Generated by coverage-comment.yml
kuangmi-bit
left a comment
There was a problem hiding this comment.
Reviewed the SSRF hardening — solid implementation. A few notes from having done the same analysis on a sibling A2A-ecosystem project (a registry service) recently:
What's done well:
- Validation happens after DNS resolution (
getaddrinfo→ check every returned address) — this catches IP literals in integer/hex forms and IPv4-mapped IPv6 (::ffff:127.0.0.1), not just dotted-quad strings - Fail-closed on unresolvable hosts (the POST would fail anyway; treating it as a pass would be a bypass)
- The
for info in infos: if blocked -> rejectloop requires ALL resolved addresses to be public, not just any — correct allow_private_push_urlsescape hatch keeps legitimate private-network webhooks working without weakening the default- Test coverage is thorough (metadata endpoint, loopback, private range, scheme, fail-closed, public allow, opt-out)
Two residual risks worth documenting (not blocking):
-
Redirect targets are not re-validated.
httpx.AsyncClientdefaults tofollow_redirects=False, but a caller can enable it — in that case the initial URL passes validation and a redirect to an internal address (e.g.https://public.example→http://169.254.169.254/) is dispatched without re-checking. Worth a doc note on thehttpx_clientparameter: "validation covers the initial URL only; keepfollow_redirects=False(the default) or the client is exposed to redirect-based SSRF." -
DNS rebinding TOCTOU window. Validation and connection are two separate resolutions; a hostile DNS server can return a public IP for the validation lookup and a private IP for the connection lookup. Hard to close fully at this layer (would require pinning the validated IP in the transport), but worth documenting as a known limitation so operators can mitigate with network controls.
Minor: consider a short docstring note in push_url_validation_error that IPv4-mapped IPv6 is covered (the is_private/is_loopback checks on mapped addresses already handle it, but the comment would save future readers a double-take).
…SSRF risks Per review from @kuangmi-bit: - Constructor now rejects an httpx.AsyncClient configured with follow_redirects=True. URL validation covers the initial URL only; with redirects enabled a validated public URL could 30x to an internal address and be dispatched unchecked. Failing fast at construction turns that misconfiguration into an explicit error. - push_url_validation_error docstring now documents the two residual risks: redirect targets are not re-validated (mitigated by the new guard) and DNS rebinding TOCTOU between validation and connection (documented as defense-in-depth; operators should keep network-level egress controls). - Notes that IPv4-mapped IPv6 forms are covered via ipaddress mapping. - Tests: setUp mocks pin follow_redirects=False explicitly; new test asserts the constructor guard raises on a redirect-following client. Full suite green: 1354 passed, 90 skipped, 3 xfailed. Signed-off-by: SashaMIT <sash.t.mitchell@gmail.com>
|
Thanks @kuangmi-bit, both points addressed in 9747ab2:
Also added the IPv4-mapped IPv6 note to the docstring as suggested, and a test asserting the constructor guard raises. Full suite green (1354 passed). |
|
Hi @SashaMIT, Re: the overlap discussion on #1169 — a few points from our side:
Happy to defer to the maintainers either way. |
Upstream's own push-notification e2e tests register loopback webhooks with real local receivers, which creation-time validation rejects by design. Add allow_private_push_urls (default False) to both request handlers and opt the test harness in, matching the dispatch-path sibling's shape (a2aproject#1164). Also: str() the getaddrinfo sockaddr host for ty, ruff-format the v1 handler test.
sokoliva
left a comment
There was a problem hiding this comment.
Thank you for this PR. Could you please resolve Lint and Check Spelling issues?
|
Thanks. Lint and Check Spelling should be clear now (type-check on the resolved address, and the unrecognized words are gone from the docstring). |
|
Following up on my comment above: confirmed — The one remaining red, With both #1164 and #1169 green, the overlap question is squarely the maintainers' call — happy to defer either way, as said. |
## Summary The `on_create_task_push_notification_config` handlers (v1 and v2) stored client-supplied URLs without any validation. A caller who can create a push notification config can point the server at loopback, private-network, link-local, or cloud-metadata hosts, and the server will POST task events to that URL on every state change. ## Root cause `src/a2a/server/request_handlers/default_request_handler.py` and `default_request_handler_v2.py` call `push_config_store.set_info(task_id, params, context)` without checking `params.url`. The dispatch path (`BasePushNotificationSender._dispatch_notification`) is covered by #1164; this PR closes the **write path**. ## Fix - Added `push_url_validation_error` to `base_push_notification_sender.py` (same logic as #1164: blocks non-http(s) schemes, loopback, private, link-local, multicast, reserved, and unresolvable hosts). - Called it in both `on_create_task_push_notification_config` handlers before storing the config. - Raises `InvalidParamsError` with a descriptive message on rejection. ## Testing - `uv run pytest tests/server/request_handlers/ -k push_notification` — 55 passed. - New test: `test_on_create_task_push_notification_config_rejects_invalid_url` covers loopback and `file:` scheme rejection. - Updated existing tests that used unresolvable fixture URLs (`1.example.com`, `callback.com`) to use `example.com` (resolvable in CI). ## Relation to #1164 #1164 validates at dispatch time (read path). This PR validates at config creation time (write path). Both are needed: write-time validation fails fast and gives the client immediate feedback; dispatch-time validation is a defense-in-depth backstop. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Iva Sokolaj <102302011+sokoliva@users.noreply.github.com>
sokoliva
left a comment
There was a problem hiding this comment.
Thank you for this PR! I left a few comments.
…dation Signed-off-by: SashaMIT <sash@ela.city> # Conflicts: # src/a2a/server/tasks/base_push_notification_sender.py
|
Left a couple more NITs, please fix and then re-request review. :) |
Drop the sender re-export so push_url_validation_error is imported from utils only.
|
Thanks Iva. I dropped the sender re-export so |
mykytanetipa
left a comment
There was a problem hiding this comment.
thank you for the contribution, couple comments from my side
Rename to validate_push_notification_url. Drop the sender __all__ re-export.
|
Thanks Iva, and thanks Mykyta. I dropped the sender |
|
Thanks Iva. Glad the dispatch-time check landed next to #1173. Appreciate the reviews from you and Mykyta. |
🤖 I have created a release *beep* *boop* --- ## [1.1.4](v1.1.3...v1.1.4) (2026-09-07) ### Features * **itk:** register itk-python-v10-agent as a uv workspace member and update dependency version markers ([#1203](#1203)) ([6eee895](6eee895)) * **itk:** use shared scenarios ([#1201](#1201)) ([b4a0b21](b4a0b21)) ### Bug Fixes * make event queue sink removal idempotent ([#1134](#1134)) ([58c72c6](58c72c6)) * omit artifacts from list tasks responses ([#1212](#1212)) ([35ef52e](35ef52e)) * owner-scope cancel/subscribe and write terminal state on cancel ([#1159](#1159), [#1170](#1170)) ([#1172](#1172)) ([ddbf853](ddbf853)) * prevent first-owner write loss in in-memory stores ([#1194](#1194)) ([bcc489c](bcc489c)) * **server:** let subscriber taps evict on full instead of wedging dispatch ([#1137](#1137)) ([0c2126f](0c2126f)) * **server:** surface producer errors after failed tasks ([#1229](#1229)) ([bc32d7e](bc32d7e)) * **server:** validate push-notification URLs at config creation ([#1173](#1173)) ([3eb88e2](3eb88e2)) * **server:** validate push-notification URLs before dispatch (SSRF hardening) ([#1164](#1164)) ([57a9df3](57a9df3)) * **server:** warn when queue_manager is ignored in DefaultRequestHandlerV2 ([#1153](#1153)) ([08fd223](08fd223)) ### Documentation * **server:** say what the evict-on-full check actually tests ([#1209](#1209)) ([4b7b242](4b7b242)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
Summary
In plain terms: when a client tells an A2A agent "send my task updates to this webhook", the agent server POSTs to whatever URL the client supplied — no checks at all. That means any client can make the agent server send requests to internal network addresses: cloud metadata endpoints (
169.254.169.254),localhostadmin panels, or unauthenticated internal services. This is the classic server-side request forgery (SSRF) pattern, and it fires on every task event.Concretely,
BasePushNotificationSender._dispatch_notificationusedpush_info.urlexactly as supplied:ftp://, future handler schemes),tasks/pushNotificationConfig/create, inline onmessage/send), so write-time validation alone wouldn't cover all of them.Fix
BasePushNotificationSendernow validates each URL at dispatch time (one choke point covering every registration path):http/https,Operators whose legitimate webhooks live on private networks opt out explicitly:
BasePushNotificationSender(..., allow_private_push_urls=True).Residual risk, stated honestly: DNS rebinding between validation and the POST itself remains possible for attacker-controlled domains (validation and the actual connection resolve the name separately). Static internal targets — the realistic SSRF cases here — are fully blocked. Noted in the constructor docstring.
Test plan
tests/server/tasks/185 pass (existing suites made DNS-hermetic)ruff check+ruff formatclean on touched filesMade with Cursor
Made with Cursor