Skip to content

[Bug]: push-notification webhook URL is not SSRF-validated (A2A spec §13.2 unimplemented) #584

Description

@ZhengShenghan

What happened?

Summary

#350 asks for SSRF and DNS-rebinding protection on the client side (ClientFactory, agent-card
discovery, DefaultAgentCardResolver). This issue is the server-side half of the same problem, which
#350 doesn't cover: the SDK's push-notification sender POSTs to a client-supplied webhook URL with no
validation at all.

A2A spec §13.2 puts this on the agent:

Agents SHOULD validate webhook URLs to prevent SSRF (Server-Side Request Forgery) attacks: Reject
private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16); Reject localhost and
link-local addresses; Implement URL allowlists where appropriate.

Verified at cfeed0d (v0.3.14). The same gap is tracked for the Python SDK in a2a-python#786.

Where

No validation exists on any hop of the server-side path:

  • src/server/request_handler/default_request_handler.ts:229 (sendMessage) and :331
    (sendMessageStream) — save an inline configuration.pushNotificationConfig, gated only on
    agentCard.capabilities.pushNotifications. :449 (setTaskPushNotificationConfig) — same, no check.
  • src/server/push_notification/push_notification_store.tssave() stores the URL as-is.
  • src/server/push_notification/default_push_notification_sender.ts_dispatchNotification:
    const url = pushConfig.url; await fetch(url, { method: 'POST', ... }).

Attack scenario

  • Victim: an operator running an agent built on @a2a-js/sdk with push notifications enabled
    (capabilities.pushNotifications: true + the SDK's DefaultPushNotificationSender), deployed
    somewhere with internal-only services (admin APIs, sidecars, cloud metadata).
  • Attacker: any client the agent will serve. No elevated privilege required — and because the config
    can be supplied inline on message/send, no pre-existing task is required either; the attacker's
    first request is enough.
  • Result: the agent POSTs the task payload to an attacker-chosen internal address, from inside the
    victim's network, carrying the attacker's X-A2A-Notification-Token.

Reproduction

Wire an agent with the SDK's own components (DefaultRequestHandler + InMemoryTaskStore +
InMemoryPushNotificationStore + DefaultPushNotificationSender) and an agent card declaring
capabilities: { pushNotifications: true } — i.e. the wiring in your own
test/server/push_notification_integration.spec.ts. Start a listener on a loopback address to stand in
for an internal-only service, then send one message pointing the webhook at it:

await handler.sendMessage({
  message: { messageId: 'm1', role: 'user', parts: [{ kind: 'text', text: 'hi' }], kind: 'message' },
  configuration: {
    pushNotificationConfig: {
      id: 'cfg-1',
      url: 'http://127.0.0.1:<port>/internal-admin/keys', // §13.2 says reject 127.0.0.0/8
      token: 'attacker-chosen-token',
    },
  },
});

Observed: the loopback listener receives POST /internal-admin/keys with the task payload and
x-a2a-notification-token: attacker-chosen-token. Expected per §13.2: the URL is rejected.

The same happens via sendMessageStream. And setTaskPushNotificationConfig accepts every address class
the spec says to reject — no error for any of these:

'http://169.254.169.254/latest/meta-data/'  // link-local / cloud metadata
'http://10.0.0.1/admin'                     // private
'http://192.168.1.1/admin'                  // private
'http://172.16.0.1/admin'                   // private
'http://localhost:9999/x'                   // localhost by name
'file:///etc/passwd'                        // scheme unrestricted

Impact

Any client of a push-enabled agent can make it issue attacker-directed POSTs from inside its trust
boundary: reaching internal-only services (including loopback/RFC-1918), cloud metadata
(169.254.169.254, environment-dependent), enumerating internal hosts/ports via timing/error
differences, and delivering task state to an attacker-chosen endpoint. Since the vulnerable sender and
store are the SDK's default components, every push-enabled agent built on @a2a-js/sdk inherits it.

Note

fetch() is called with no redirect option, so it defaults to follow. A webhook URL that passes a
naive string check can 307-redirect to an internal address and the SDK follows it, POST and body
preserved. So validating the URL string alone isn't enough — the resolved IP needs to be validated and
pinned. (This is the same DNS-rebinding concern #350 raises for the client side.)

Proposed direction (looking for input from reviewers)

The intent is not to block private/loopback unconditionally — those are legitimate targets for
internal agents, sidecars, and dev setups. (a2a-python#895 was closed for validating unconditionally; the
maintainers' follow-up design is a2a-python#1023.) Mirroring that:

  1. A composable, opt-out validator: reject private/loopback/link-local by default, with a host/CIDR
    allowlist.
  2. Restrict scheme to HTTP(S) (or HTTPS-only, configurable).
  3. Validate, then connect to the pinned resolved IP; set redirect: 'error' on the dispatch fetch.
  4. Make it injectable so integrators can supply their own policy.

Since #350 wants the client-side half, one shared validator could serve both — which is what a2a-python#1023
scopes (UrlValidator + per-domain wrappers). Happy to implement it here if that direction looks right.


Code of Conduct

  • I agree to follow this project's Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions