a2aproject / a2aproject/a2a-js

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

Offen
#584 1 Kommentar 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
Vorherrschende Sprache
TypeScript
Sterne
613
Forks
169
Ø Merge
1 T. 6 Std.
Gemergte PRs (30 T.)
21

Beschreibung

### 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:

```ts
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:/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:

```ts
'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

- [x] I agree to follow this project's Code of Conduct

Beitragsleitfaden

Beitragsleitfaden öffnen

Bewertung

Dieses Issue wurde noch nicht bewertet.

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.