Skip to content

[Task]: Prepare URL validation infrastructure for agent card and webhook SSRF protection #1023

Open

Description

@ishymko

Context

We have two places which can benefit from SSRF protection:

  1. Client: fetching agent cards and using URLs to make A2A calls (brought in fix(security): SSRF via AgentCard URL and context ID Injection (A2A-SSRF-01, A2A-INJ-01) #895, filed a separate issue [Feat]: Agent card validation hook #975).
  2. Push notification webhooks URLs (Security Improvement: Add SSRF protection for Push Notification webhooks and authorization checks for Task operations #786): there is a spec statement about SSRF protection there.

Description

Both cases above can share domain agnostic machinery for URL validation.

The following requirements apply:

  1. Composable: local addresses are perfectly fine for some deployments.
  2. Validation should happen before the actual invocation and invocation should use "pinned" IP to protect from DNS rebinding.
  3. Built-in rules: RequireScheme (to restrict HTTP(S)-only or HTTPS-only), BlockPrivateNetworks (with allowlist for deployments where using private networks is a legitimate use-case).

This issue scopes to UrlValidator only and should be a foundation for #975 and #786 where appropriate domain validators are going to be implemented.

flowchart TB
    subgraph Wrappers["Domain wrappers (own which fields to validate + domain error type)"]
        direction LR
        PN["PushNotificationUrlValidator"]
        AC["AgentCardUrlValidator"]
    end
    V["UrlValidator<br/>parse → resolve → run rules in order<br/>(rule raises = reject, returns = continue)"]
    subgraph Rules["Composable rules"]
        direction LR
        RS["RequireScheme"]
        BPN["BlockPrivateNetworks<br/>(allow_hosts, allow_cidrs)"]
        CR["…custom…"]
    end
    PN --> V
    AC --> V
    V --> Rules
    class PN,AC wrap
    class V core
    class RS,BPN,HD,CR rule
Loading

Sketch:

class InvalidUrlError(ValueError):
    """Raised by UrlValidator.validate when a URL is rejected."""

class UrlValidationRule(ABC):
    @abstractmethod
    async def check(self, url: ResolvedUrl) -> None:
        """Raise InvalidUrlError to reject url."""

class ResolvedUrl:
    raw: str
    parsed: SplitResult
    addresses: tuple[IpAddress, ...]


class UrlValidator:
    def __init__(
        self,
        rules: Sequence[UrlValidationRule] = (),
        resolve: bool = True,
    ) -> None:    
    . . .

    async def validate(self, url: str) -> ResolvedUrl:
        # Conditionally resolves IPs via getaddrinfo
        resolved = await self._build(url)

        # Just runs all rules one by one, they throw to stop
        for rule in self._rules:
            await rule.check(resolved)

        # Returns URL so that pinned address can be used
        return resolved

Activity

  1. added theissue type on Apr 27, 2026
  2. added
    component: coreIssues related to base data models, auth, gRPC interfaces, observability, and fundamental utilities.
    on Apr 27, 2026
  3. Ryujiyasu commented on Apr 28, 2026

    @Ryujiyasu

    Thanks for spinning this out, @ishymko — happy to see the shared infrastructure land before the per-domain wrappers. A few thoughts from the threat-modeling side of #786:

    1. How does ResolvedUrl.addresses actually get used by the caller?

    The sketch returns the resolved addresses but doesn't pin httpx to them. Without an explicit pinning mechanism (e.g., a custom httpx.BaseTransport that rewrites request.url.host to the validated IP while preserving the Host header and TLS SNI — see Drawbridge for prior art), there's still a TOCTOU window between getaddrinfo and the actual socket.connect. Worth making this part of the contract, not an exercise for downstream implementers.

    2. Redirect handling.

    The current design is silent on redirects. Established practice (GitLab, Drawbridge, Advocate) is to re-validate every hop, not disable redirects. pyload-ng's CVE-2026-35459 is a recent reminder of what happens when this is skipped. Suggest the validator either owns the redirect loop or documents that callers MUST run each Location back through validate().

    3. BlockPrivateNetworks is not enough on its own.

    ipaddress.IPv6Address.is_private has known gaps that an SSRF-focused blocklist needs to handle explicitly:

    • NAT64 64:ff9b::/96 (64:ff9b::a9fe:a9fe reaches AWS IMDS but is_private=False)
    • 6to4 2002::/16 (embedded IPv4 not extracted by .ipv4_mapped)
    • CGNAT 100.64.0.0/10 (covers Alibaba metadata 100.100.100.200)

    And cloud metadata endpoints worth treating as first-class blocks even though some are technically link-local:

    • AWS ECS task credentials 169.254.170.2
    • Oracle Cloud 192.0.0.192 (RFC 7600 — not link-local!)
    • Alibaba 100.100.100.200 (CGNAT — not RFC 1918)

    A BlockCloudMetadata rule alongside BlockPrivateNetworks might be worth surfacing given how often this is the actual exploit target.

    4. getaddrinfo resolution policy.

    Suggest getaddrinfo(host, None, AF_UNSPEC) and rejecting if any returned address fails — Craft CMS GHSA-v2gc-rm6g-wrw9 is the canonical example of what goes wrong when only the v4 record is checked.


    Happy to help draft the test matrix for the Push Notification wrapper once the core validator lands. Also happy to take a stab at a PR for either side if that's useful — let me know what would fit your workflow.

  4. matanweks commented on May 10, 2026

    @matanweks
    Contributor

    Happy to take this. Quick alignment before I push code:

    • Module location: a2a.security.url_validators or keep a2a.utils.url_validation (where PR fix(security): SSRF via AgentCard URL and context ID Injection (A2A-SSRF-01, A2A-INJ-01) #895 currently puts it)?
    • Redirect handling: should UrlValidator own the loop (re-validate each Location) or document that callers must run each hop through validate()? Leaning toward the former per Ryujiyasu's note about CVE-2026-35459.
    • BlockCloudMetadata as a separate built-in rule (covers 169.254.170.2, 192.0.0.192, 100.100.100.200), or fold into BlockPrivateNetworks?

    On overlap with #895: I'll structure around your composable-rules sketch; happy to update if #895 lands first or gets superseded.

  5. added a commit that references this issue on Jun 26, 2026
    9ce1bc3
  6. kokotatan commented on Oct 4, 2026

    @kokotatan
    Contributor

    @ishymko A scope question before anyone picks this up again. Since this was filed, push-notification URL checks landed as a2a.utils.push_url_validator (#1164, #1173), and #975 is now maintainers-only. The last update here was in May.

    Is this issue still open to external contributors? If so, would you want it as a refactor that extracts the generic part of push_url_validator into the composable UrlValidator / rules from your sketch (with the push path switched over and its behavior unchanged), leaving the agent-card wrapper for #975 to you? Or would you rather keep it with the maintainers too?

  7. Linux2010 commented on Oct 10, 2026

    @Linux2010
    Contributor

    @ishymko — adding my voice to @kokotatan's scope question above, plus a concrete starting point: I've opened a draft PR implementing your sketch as-is (#1333), so the discussion can happen against working code.

    What the draft contains:

    • UrlValidator core (parse → optional resolve → rules in order → returns ResolvedUrl with the resolved addresses, per your sketch)
    • RequireScheme + BlockPrivateNetworks (allow_hosts/allow_cidrs) as the first built-in rules
    • PushNotificationUrlValidator: validate_push_notification_url now delegates to the composable stack — boolean, fail-closed contract unchanged; the existing SSRF tests pass unmodified
    • AgentCardUrlValidator: agent card URLs screened (http/https, public by default, private networks opt-in) — the remaining [Task]: Prepare URL validation infrastructure for agent card and webhook SSRF protection #1023 scope beyond push

    Questions before I mark it ready:

    1. Module location: a2a/utils/url_validation.py (next to push_url_validator) vs a dedicated namespace — any preference?
    2. Connection pinning: ResolvedUrl already returns the resolved addresses for it. Should the pinning transport land in this PR or as an immediate follow-up? (Your scope line says "UrlValidator only", so I scoped it out.)
    3. Relation to [Feat]: Agent card validation hook #975: I read the card validation hook as a consumer of this foundation — correct?

    Context: I had an earlier attempt at this scope (#1117, closed once the push-specific parts landed first), and recently hardened push_url_validator's error boundary in #1329. The draft is fully tested (26 tests; full suite green; push behavior byte-for-byte identical).

    Happy to re-shape to whatever scope you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    component: coreIssues related to base data models, auth, gRPC interfaces, observability, and fundamental utilities.

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions