Skip to content

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

Merged
sokoliva merged 8 commits into
a2aproject:mainfrom
SashaMIT:fix/push-notification-url-ssrf-validation
Sep 3, 2026
Merged

fix(server): validate push-notification URLs before dispatch (SSRF hardening)#1164
sokoliva merged 8 commits into
a2aproject:mainfrom
SashaMIT:fix/push-notification-url-ssrf-validation

Conversation

@SashaMIT

@SashaMIT SashaMIT commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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

…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>
@SashaMIT

SashaMIT commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @kuangmi-bit, both points addressed in 9747ab2:

  1. Redirects: the constructor now rejects an httpx.AsyncClient with follow_redirects=True outright, with a ValueError explaining why. That turns the redirect-SSRF misconfiguration into a fail-fast at startup instead of a doc note people may miss. The residual-risk note is also in the httpx_client docstring.
  2. DNS rebinding TOCTOU: documented as a known limitation in push_url_validation_error (validation and connection resolve separately; pinning the validated IP belongs in the transport layer), framed as defense-in-depth with network-level egress controls as the operator mitigation.

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

@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.

Both points from my earlier review are addressed in 9747ab2 (fail-fast on follow_redirects=True, plus the second item). The SSRF hardening looks solid — approving. (cc: the duplicate #1169 covers the same ground; consider closing one.)

@ez-lbz

ez-lbz commented Aug 9, 2026

Copy link
Copy Markdown

Hi @SashaMIT,

Re: the overlap discussion on #1169 — a few points from our side:

  1. #1169 is part of a batch of findings from a test framework we are building to exercise the A2A protocol across the SDKs; the whole batch was filed on the same day.
  2. Between two PRs covering the same fix, the deciding factor should be the check results, not the filing order. As of now, #1164 has not passed all of its required checks — and per project rules, a PR that has not passed all checks cannot be accepted.
  3. On that basis, review should treat #1169 as the primary candidate; if #1164 is to be considered at all, its failing checks need to be resolved first.

Happy to defer to the maintainers either way.

SashaMIT added a commit to SashaMIT/a2a-python that referenced this pull request Aug 9, 2026
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 sokoliva left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you for this PR. Could you please resolve Lint and Check Spelling issues?

@SashaMIT

Copy link
Copy Markdown
Contributor Author

Thanks. Lint and Check Spelling should be clear now (type-check on the resolved address, and the unrecognized words are gone from the docstring).

@ez-lbz

ez-lbz commented Aug 28, 2026

Copy link
Copy Markdown

Following up on my comment above: confirmed — Lint Code Base and Check Spelling now pass on the current head, so that objection is resolved.

The one remaining red, ITK, looks transient rather than code-related: the run logs show Error response from daemon: No such container: itk-service (the star-topology peers — go_v03/go_v10/python_v03/python_v10 — never came up), and ITK failed for two unrelated PRs at the same minute (17:55) while runs for other branches before and after that window succeed. A rerun should clear it.

With both #1164 and #1169 green, the overlap question is squarely the maintainers' call — happy to defer either way, as said.

sokoliva added a commit that referenced this pull request Aug 31, 2026
## 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>
Comment thread src/a2a/server/tasks/base_push_notification_sender.py Outdated
Comment thread src/a2a/server/tasks/base_push_notification_sender.py Outdated
Comment thread src/a2a/server/tasks/base_push_notification_sender.py Outdated

@sokoliva sokoliva left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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
@SashaMIT

Copy link
Copy Markdown
Contributor Author

Thanks Iva. I moved the validator to src/a2a/utils/push_url_validator.py, defaulted the sender hook to None like #1173 (the docstring points at push_url_validation_error), and dropped the follow_redirects constructor raise. This branch is now on current main so it sits on top of #1173.

Comment thread src/a2a/server/tasks/base_push_notification_sender.py Outdated
Comment thread src/a2a/utils/push_url_validator.py Outdated
@sokoliva

sokoliva commented Sep 1, 2026

Copy link
Copy Markdown
Member

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.
@SashaMIT

SashaMIT commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Iva. I dropped the sender re-export so push_url_validation_error is imported from a2a.utils.push_url_validator only, and wrapped urlparse plus parsed.port so a bad port returns unparseable instead of raising. Re-requesting review.

Comment thread src/a2a/server/tasks/base_push_notification_sender.py Outdated

@mykytanetipa mykytanetipa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

thank you for the contribution, couple comments from my side

Comment thread src/a2a/utils/push_url_validator.py Outdated
Comment thread src/a2a/utils/push_url_validator.py Outdated
Comment thread src/a2a/utils/push_url_validator.py Outdated
Comment thread src/a2a/utils/push_url_validator.py Outdated
Comment thread src/a2a/utils/push_url_validator.py Outdated
Comment thread src/a2a/utils/push_url_validator.py Outdated
Comment thread src/a2a/utils/push_url_validator.py Outdated
Comment thread tests/server/tasks/test_inmemory_push_notifications.py
@SashaMIT

SashaMIT commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Iva, and thanks Mykyta.

I dropped the sender __all__ so BasePushNotificationSender is exported only from a2a.server.tasks. The helper is now validate_push_notification_url and returns True when the URL is safe. Rejects log with logger.warning in the validator and no longer travel as raw strings. The handler hook is Callable[[str], Awaitable[bool]]. Restored the unrelated blank line in the in-memory dispatch test.

@sokoliva
sokoliva merged commit 57a9df3 into a2aproject:main Sep 3, 2026
17 checks passed
@SashaMIT

SashaMIT commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Iva. Glad the dispatch-time check landed next to #1173. Appreciate the reviews from you and Mykyta.

mykytanetipa pushed a commit that referenced this pull request Sep 8, 2026
🤖 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).
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.

5 participants