Skip to content

a2a: guard push-notification callbacks against SSRF - #4849

Merged
asim merged 1 commit into
masterfrom
claude/a2a-push-ssrf
Jul 15, 2026
Merged

a2a: guard push-notification callbacks against SSRF#4849
asim merged 1 commit into
masterfrom
claude/a2a-push-ssrf

Conversation

@asim

@asim asim commented Jul 15, 2026

Copy link
Copy Markdown
Member

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/set records a URL and deliverPush POSTs 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:

  • Validated when setsetPushConfig rejects 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 clear invalid params rejection and nothing is stored.
  • Re-checked at delivery, pinned at dial timedeliverPush re-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) and a2a.WithPushURLPolicy(...) (embedded NewAgentHandler/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, and golangci-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

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
Copilot AI review requested due to automatic review settings July 15, 2026 10:41

Copilot AI 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.

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 setPushConfig and re-checks at delivery time in deliverPush, 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 thread gateway/a2a/a2a.go
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 thread gateway/a2a/a2a.go
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 thread gateway/a2a/a2a.go
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
}
@asim
asim merged commit bbeb3ac into master Jul 15, 2026
13 checks passed
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.

Security: A2A push callback URL trust boundary needs private triage

3 participants