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.ts — save() 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:
- A composable, opt-out validator: reject private/loopback/link-local by default, with a host/CIDR
allowlist.
- Restrict scheme to HTTP(S) (or HTTPS-only, configurable).
- Validate, then connect to the pinned resolved IP; set
redirect: 'error' on the dispatch fetch.
- 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
What happened?
Summary
#350 asks for SSRF and DNS-rebinding protection on the client side (
ClientFactory, agent-carddiscovery,
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:
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 inlineconfiguration.pushNotificationConfig, gated only onagentCard.capabilities.pushNotifications.:449(setTaskPushNotificationConfig) — same, no check.src/server/push_notification/push_notification_store.ts—save()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
@a2a-js/sdkwith push notifications enabled(
capabilities.pushNotifications: true+ the SDK'sDefaultPushNotificationSender), deployedsomewhere with internal-only services (admin APIs, sidecars, cloud metadata).
can be supplied inline on
message/send, no pre-existing task is required either; the attacker'sfirst request is enough.
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 declaringcapabilities: { pushNotifications: true }— i.e. the wiring in your owntest/server/push_notification_integration.spec.ts. Start a listener on a loopback address to stand infor an internal-only service, then send one message pointing the webhook at it:
Observed: the loopback listener receives
POST /internal-admin/keyswith the task payload andx-a2a-notification-token: attacker-chosen-token. Expected per §13.2: the URL is rejected.The same happens via
sendMessageStream. AndsetTaskPushNotificationConfigaccepts every address classthe spec says to reject — no error for any of these:
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/errordifferences, 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/sdkinherits it.Note
fetch()is called with noredirectoption, so it defaults tofollow. A webhook URL that passes anaive string check can
307-redirect to an internal address and the SDK follows it, POST and bodypreserved. 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:
allowlist.
redirect: 'error'on the dispatchfetch.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