Skip to content

fix(server): validate push-notification URLs before dispatch (SSRF hardening) - #1164

Open
SashaMIT wants to merge 1 commit into
a2aproject:mainfrom
SashaMIT:fix/push-notification-url-ssrf-validation
Open

fix(server): validate push-notification URLs before dispatch (SSRF hardening)#1164
SashaMIT wants to merge 1 commit into
a2aproject:mainfrom
SashaMIT:fix/push-notification-url-ssrf-validation

Conversation

@SashaMIT

@SashaMIT SashaMIT commented Aug 5, 2026

Copy link
Copy Markdown

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), localhost admin 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_notification used push_info.url exactly as supplied:

  • no scheme restriction (ftp://, future handler schemes),
  • no destination restriction (loopback / link-local / RFC1918 / reserved),
  • and configs can be registered through multiple paths (tasks/pushNotificationConfig/create, inline on message/send), so write-time validation alone wouldn't cover all of them.

Fix

BasePushNotificationSender now validates each URL at dispatch time (one choke point covering every registration path):

  • scheme must be http/https,
  • host must resolve (unresolvable fails closed — the POST would fail anyway),
  • every resolved address must be public unicast; loopback, link-local, private, reserved, multicast, and unspecified addresses are rejected.

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

  • 7 new unit tests: metadata IP blocked, loopback blocked, private range blocked, non-HTTP scheme blocked, unresolvable host fails closed, public host allowed, opt-out allows private
  • tests/server/tasks/ 185 pass (existing suites made DNS-hermetic)
  • Push-notification e2e suite passes (test app opts out — its webhooks are real local servers)
  • ruff check + ruff format clean on touched files

Made with Cursor

Made with Cursor

…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>
@SashaMIT
SashaMIT requested a review from a team as a code owner August 5, 2026 21:55
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/server/events/event_queue_v2.py 91.79% 91.28% 🔴 -0.51%
src/a2a/server/tasks/base_push_notification_sender.py 94.44% 89.87% 🔴 -4.57%
src/a2a/utils/telemetry.py 91.47% 90.70% 🔴 -0.78%
Total 93.00% 92.94% 🔴 -0.06%

Generated by coverage-comment.yml

@kuangmi-bit kuangmi-bit left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 -> reject loop requires ALL resolved addresses to be public, not just any — correct
  • allow_private_push_urls escape 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):

  1. Redirect targets are not re-validated. httpx.AsyncClient defaults to follow_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.examplehttp://169.254.169.254/) is dispatched without re-checking. Worth a doc note on the httpx_client parameter: "validation covers the initial URL only; keep follow_redirects=False (the default) or the client is exposed to redirect-based SSRF."

  2. 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).

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