fix(server): validate push-notification URLs before dispatch (SSRF hardening) - #1164
fix(server): validate push-notification URLs before dispatch (SSRF hardening)#1164SashaMIT wants to merge 7 commits into
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.45% | 🟢 +14.03% |
| src/a2a/utils/push_url_validator.py (new) | — | 87.80% | — |
| Total | 92.97% | 93.06% | 🟢 +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
| __all__ = [ | ||
| 'BasePushNotificationSender', | ||
| 'push_url_validation_error', | ||
| ] |
There was a problem hiding this comment.
Please remove. Since we moved push_url_validation_error to utils, let's also import from
from a2a.utils.push_url_validator
Alternatively, You can add push_url_validation_error to a2a/utils/__init__.py.
| host = parsed.hostname | ||
| if not host: | ||
| return 'no hostname' | ||
| port = parsed.port or (443 if parsed.scheme == 'https' else 80) |
There was a problem hiding this comment.
It seems port also raises ValueError: port
Let's also put it in a try/except block to catch those errors, something like:
try:
parsed = urllib.parse.urlparse(url)
explicit_port = parsed.port
except ValueError:
return 'unparseable URL'
(.... other code ...)
port = explicit_port or (443 if parsed.scheme == 'https' else 80)
|
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 |
| __all__ = [ | ||
| 'BasePushNotificationSender', | ||
| ] |
There was a problem hiding this comment.
Please remove this too. We export it here so there is no need to export it also in this file.
mykytanetipa
left a comment
There was a problem hiding this comment.
thank you for the contribution, couple comments from my side
| ) | ||
|
|
||
|
|
||
| async def push_url_validation_error(url: str) -> str | None: |
There was a problem hiding this comment.
can we rename this to smth like validate_push_notification_url? the "error" prefix kinda distorts the purpose of the function which is validation.
| ) | ||
|
|
||
|
|
||
| async def push_url_validation_error(url: str) -> str | None: |
There was a problem hiding this comment.
since the purpose is validation I would change the return type to bool instead of "str | None", it's uncommon to use None for "safe" outcome
| parsed = urllib.parse.urlparse(url) | ||
| explicit_port = parsed.port | ||
| except ValueError: | ||
| return 'unparseable URL' |
There was a problem hiding this comment.
please avoid propagation of errors as raw strings, if the intent is logging only please do it inplace with logger.warning
| except ValueError: | ||
| return 'unparseable URL' | ||
| if parsed.scheme not in ('http', 'https'): | ||
| return f"scheme '{parsed.scheme}' is not http/https" |
| return f"scheme '{parsed.scheme}' is not http/https" | ||
| host = parsed.hostname | ||
| if not host: | ||
| return 'no hostname' |
| loop = asyncio.get_running_loop() | ||
| infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) | ||
| except OSError: | ||
| return f"host '{host}' could not be resolved" |
| return f"host '{host}' could not be resolved" | ||
| for info in infos: | ||
| if _ip_is_blocked(str(info[4][0])): | ||
| return f"host '{host}' resolves to a non-public address" |
| self.mock_httpx_client.post.return_value = mock_response | ||
|
|
||
| self.config_store = InMemoryPushNotificationConfigStore() | ||
|
|
There was a problem hiding this comment.
minor: unrelated edit
Rename to validate_push_notification_url. Drop the sender __all__ re-export.
|
Thanks Iva, and thanks Mykyta. I dropped the sender |
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