a2a: guard push-notification callbacks against SSRF - #4849
Merged
Conversation
The A2A gateway's push-notification flow (tasks/pushNotificationConfig/set → deliverPush) POSTed task state to a caller-supplied URL via the default HTTP client, so an untrusted A2A caller could aim the gateway at internal addresses (loopback, link-local cloud metadata, RFC1918) it would otherwise never reach — a server-side request forgery vector (#4129). Add a default SSRF-safe policy: only http/https callbacks whose host does not resolve to a loopback, private, link-local, multicast, or unspecified address. It's enforced when the config is set (caller gets a clear rejection, nothing stored) and again at delivery, and the delivery client re-checks the resolved IP at dial time so a name that passes validation can't be rebound to an internal address before connect. Operators that need a trusted in-cluster receiver set Options.AllowPushURL (gateway) or a2a.WithPushURLPolicy (embedded handlers) to own the policy; that path skips the built-in private-IP dial guard by design. Tests cover blocked/allowed URLs, the dial-time guard, set-time rejection, default-deny delivery, and the operator override. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL
There was a problem hiding this comment.
Pull request overview
Adds SSRF defenses to the A2A gateway’s push-notification callback flow by validating callback URLs at registration time and enforcing IP-based blocking at connection time to prevent private/loopback/link-local reachability (including DNS rebinding protections), with an explicit operator override hook.
Changes:
- Introduces a default push callback URL policy and a guarded HTTP client with dial-time IP checks to block internal/unsafe destinations.
- Enforces callback URL validation in
setPushConfigand re-checks at delivery time indeliverPush, with operator-configurable policy overrides (Options.AllowPushURL,WithPushURLPolicy). - Adds focused tests and documents the security change in the changelog.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| gateway/a2a/pushsecurity.go | Adds default SSRF-safe URL policy, blocked-IP logic, and guarded HTTP client for push delivery. |
| gateway/a2a/pushsecurity_test.go | Adds unit tests covering policy decisions, dial-time blocking, and override behavior. |
| gateway/a2a/a2a.go | Wires policy into gateway and embedded handlers; validates callbacks on set and (re-)checks on delivery; switches delivery client. |
| gateway/a2a/a2a_test.go | Updates push-config delivery test to authorize loopback receiver via policy override. |
| gateway/a2a/client_test.go | Updates embedded-agent test to authorize loopback push receiver via WithPushURLPolicy. |
| CHANGELOG.md | Documents the new A2A push-notification SSRF guard and override mechanism. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+100
to
+106
| Transport: &http.Transport{ | ||
| Proxy: http.ProxyFromEnvironment, | ||
| DialContext: (&net.Dialer{ | ||
| Timeout: 5 * time.Second, | ||
| Control: pushDialControl, | ||
| }).DialContext, | ||
| }, |
Comment on lines
+554
to
+558
| // allowPushURL authorizes an outbound push-notification callback URL; nil | ||
| // means the default SSRF-safe policy. guardPushDial applies the private-IP | ||
| // dial guard (on unless an operator supplied a custom policy). | ||
| allowPushURL func(*url.URL) error | ||
| guardPushDial bool |
Comment on lines
+806
to
+810
| // Reject SSRF-unsafe callback targets before storing them. | ||
| if err := d.checkPushURL(p.PushNotificationConfig.URL); err != nil { | ||
| writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "push notification url not allowed"}) | ||
| return | ||
| } |
Comment on lines
+979
to
+983
| // Defense in depth: re-validate the callback URL at delivery time in case | ||
| // the policy tightened or the config was set before it applied. | ||
| if err := d.checkPushURL(cfg.URL); err != nil { | ||
| return | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4129.
Problem
The A2A gateway's push-notification flow is the one place it makes an outbound HTTP request to a caller-supplied address:
tasks/pushNotificationConfig/setrecords a URL anddeliverPushPOSTs task state to it via the default HTTP client. With no guard, an untrusted A2A caller can point the gateway at addresses it would otherwise never reach (loopback, link-local, private ranges) — a server-side request forgery vector, and the risk flagged in #4129.Fix
A default SSRF-safe policy, defense-in-depth at two layers:
setPushConfigrejects a callback whose scheme isn't http/https, or whose host resolves to a loopback, private (RFC1918/ULA), link-local (incl. the cloud-metadata range), multicast, or unspecified address. The caller gets a clearinvalid paramsrejection and nothing is stored.deliverPushre-validates, and the delivery client's dialer inspects the resolved IP immediately before connect and refuses blocked addresses. That closes the gap between name-validation and connection (DNS rebinding): a hostname that passed validation can't be swapped to an internal IP before the socket opens.Operator override
Legitimate deployments sometimes push to a trusted in-cluster receiver.
Options.AllowPushURL func(*url.URL) error(gateway) anda2a.WithPushURLPolicy(...)(embeddedNewAgentHandler/NewAgentStreamHandler) let the operator own the policy; supplying one hands them the trust decision and skips the built-in private-IP dial guard by design. Both are additive and backward-compatible (variadic option; default behavior unchanged for existing callers, except that unconfigured callbacks to internal IPs are now refused).Tests
TestDefaultPushURLPolicy— blocked (loopback, metadata range, RFC1918, IPv6 loopback/ULA, unspecified, private-resolving hostname, mixed public+internal DNS, non-http(s) scheme, no-host) vs allowed (public literal IP, public hostname).TestPushDialControlBlocksPrivate— the dial-time guard rejects private/loopback/link-local/unspecified and permits public.TestSetPushConfigRejectsSSRFURL— an internal-target config is refused and not stored.TestDeliverPushBlocksInternalByDefault— a config that bypasses set-time validation still isn't delivered to a loopback target.TestAllowPushURLOverrideDelivers— an operator policy authorizes a trusted receiver and delivery proceeds.go build ./...,go test -race ./gateway/a2a/...,go vet, andgolangci-lint run ./gateway/a2a/...pass. No other in-repo caller configures push delivery, so nothing else is affected.🤖 Generated with Claude Code
https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL
Generated by Claude Code