SSRF Protection for AI Agents: Blocking Internal Access

TL;DR — SSRF is one of the most dangerous attack vectors for AI agents. An agent with URL-fetching tools is one prompt injection away from leaking cloud credentials, accessing internal services, or scanning your network. Block cloud metadata endpoints (169.254.169.254), all private CIDR ranges (IPv4 and IPv6), and non-http(s) schemes. Canonicalize URLs before any policy check — decode percent-encoding, normalize numeric IP forms, normalize IPv4-mapped IPv6. Resolve hostnames yourself, validate the IP, then connect using that IP with DNS pinning to prevent rebinding. Route all agent HTTP traffic through an egress proxy that enforces these controls. The agent should never make direct network requests.

Why Agent SSRF Is Different (and Worse)

SSRF was a server-side problem before AI agents existed. A web app would accept a URL parameter, fetch it server-side, and return the body. Attackers used that fetcher to reach internal services the public could not.

AI agents make SSRF exponentially more dangerous:

  • No URL review: A web app developer chooses which endpoints accept URLs. An agent will follow any link it reads — including links inside fetched HTML, MCP tool responses, and tool descriptions — with no stop to ask whether http://169.254.169.254/latest/meta-data/iam/security-credentials/role looks suspicious.

  • Multiple parsers in the chain: The LLM writes a URL string. The MCP client parses it. The agent's HTTP library parses it. The DNS resolver parses the hostname. Each layer normalizes differently, and bypasses live in the gaps between parsers.

  • Cloud-resident agents: Most production agents run on AWS, GCP, or Azure. The 169.254.169.254 metadata service is reachable by default unless someone explicitly blocked it.

  • Prompt-injected URL fields: A poisoned MCP tool description can plant a metadata URL into the agent's context. The agent then constructs a tool call that includes that URL as a parameter. The MCP server fetches it. This is not theoretical — Invariant Labs disclosed tool poisoning attacks in April 2025.

  • Agent autonomy: A web app's SSRF surface is limited to specific endpoints. An agent with a URL-fetch tool can be directed to fetch any URL the model chooses, based on any text the model has read — including adversarial content from web pages, emails, or support tickets.

Attack Vectors

Cloud Metadata Endpoints

Every major cloud exposes instance metadata at the link-local IPv4 address 169.254.169.254:

Cloud Metadata Endpoint What It Leaks
AWS http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> IAM access keys, secret keys, session tokens
GCP http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token Service account OAuth tokens
Azure http://169.254.169.254/metadata/instance?api-version=2021-02-01 Instance details, identity tokens

AWS introduced IMDSv2 to require a session token before the metadata service responds. This raises the bar for accidental exposure but does not block an agent that can issue both the token request and the credentials request in sequence.

IPv6 widens the surface: AWS documents fd00:ec2::254 as the IPv6 metadata address. GCP supports IPv6 metadata in dual-stack VPCs. Any SSRF blocklist that only covers IPv4 leaves the IPv6 path open.

The 2019 Capital One breach hinged on a misconfigured WAF that allowed an attacker to reach the EC2 metadata service from a server-side fetcher. The attack pattern is identical for agents: any tool that fetches a URL from a cloud VM is one prompt injection away from leaking IAM credentials.

Private CIDR Ranges

The standard private ranges must be blocked at minimum:

IPv4:
- 10.0.0.0/8 (private)
- 172.16.0.0/12 (private)
- 192.168.0.0/16 (private)
- 169.254.0.0/16 (link-local)
- 127.0.0.0/8 (loopback)
- 100.64.0.0/10 (CGN)
- 0.0.0.0/8 (current network)

IPv6:
- fc00::/7 (ULA)
- fe80::/10 (link-local)
- ::1/128 (loopback)

Container and Kubernetes deployments add more: pod CIDRs, service CIDRs, and node management addresses. The blocklist must cover every range that resolves to internal infrastructure, not only the IETF private blocks.

DNS Rebinding

DNS rebinding works because some applications resolve a hostname twice. The first lookup feeds the policy check. The second lookup, performed at connection time, feeds the socket call.

An attacker controls the authoritative nameserver for the host and returns:
1. First query: TTL 0, points to a public IP that passes the allowlist
2. Second query (a moment later): TTL 0, points to 127.0.0.1 or a private IP

The application validates the public IP, then connects to the private IP. The fetcher reaches localhost services that never expected outbound traffic.

Agents are vulnerable for the same reason web servers were: the resolution and the connection are not bound to the same IP.

IPv4-Mapped IPv6

The address ::ffff:127.0.0.1 is 127.0.0.1 viewed through an IPv6 socket. A blocklist that checks only IPv4 strings or only IPv6 strings can miss it. The dual-stack representation must be normalized before the CIDR check.

Numeric IP Encoding

Most URL parsers accept IPs in non-dotted forms:

Encoding Example Equals
Decimal 2130706433 127.0.0.1
Hex 0x7f000001 127.0.0.1
Octal 0177.0.0.1 127.0.0.1
Mixed 127.1 127.0.0.1

A naive substring check for 127.0.0.1 misses every encoded variant. The defense is canonicalization before any policy check, not regex on the raw string.

Scheme Abuse

URL schemes beyond http and https can access local resources:

  • file:///etc/passwd — local file access
  • gopher:// — arbitrary TCP connections
  • dict:// — DICT protocol
  • ftp:// — file transfer
  • ldap:// — LDAP queries

The default scheme allowlist must be http and https only. Reject everything else.

Parser Differential Vulnerabilities

Different URL parsers normalize differently. A URL that passes validation in one parser may resolve to a different target in another:

  • http://evil.com#@good.com/ — fragment vs. hostname confusion
  • http://evil.com\@good.com/ — backslash vs. forward slash
  • http://good.com.evil.com/ — subdomain vs. domain
  • http://evil.com:80@good.com/ — userinfo vs. port

The defense: use the same parser for validation and connection, or canonicalize before any check and use the canonical form for both.

Defenses That Work

1. URL Canonicalization (Before Any Check)

Decode and normalize the URL before any policy evaluation:

import ipaddress
import urllib.parse

def canonicalize_url(raw_url: str) -> str:
    parsed = urllib.parse.urlparse(raw_url)

    # Reject non-http(s) schemes
    if parsed.scheme not in ('http', 'https'):
        raise ValueError(f"Scheme not allowed: {parsed.scheme}")

    # Strip trailing dots, lowercase hostname
    hostname = parsed.hostname.rstrip('.').lower()

    # Reject embedded credentials
    if parsed.username or parsed.password:
        raise ValueError("Embedded credentials not allowed")

    # Normalize hostname: if it's a numeric IP, canonicalize it
    try:
        # This handles decimal, hex, octal, and mixed forms
        ip = ipaddress.ip_address(hostname)
        hostname = str(ip)
    except ValueError:
        pass  # It's a DNS hostname, not an IP

    # Rebuild URL with canonical hostname
    return urllib.parse.urlunparse((
        parsed.scheme,
        f"{hostname}:{parsed.port}" if parsed.port else hostname,
        parsed.path,
        parsed.params,
        parsed.query,
        ''  # Drop fragment
    ))

2. IP Resolution and Validation

Resolve the hostname yourself, validate the IP, then connect using that validated IP:

import socket
import ipaddress

BLOCKED_RANGES = [
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("169.254.0.0/16"),
    ipaddress.ip_network("127.0.0.0/8"),
    ipaddress.ip_network("100.64.0.0/10"),
    ipaddress.ip_network("0.0.0.0/8"),
    ipaddress.ip_network("fc00::/7"),
    ipaddress.ip_network("fe80::/10"),
    ipaddress.ip_network("::1/128"),
    # Add cloud-specific ranges
    ipaddress.ip_network("fd00:ec2::/16"),  # AWS IPv6 metadata
]

def resolve_and_validate(hostname: str) -> str:
    """Resolve hostname and validate IP. Returns validated IP."""
    try:
        # Get all addresses (IPv4 and IPv6)
        addrs = socket.getaddrinfo(hostname, None)
    except socket.gaierror:
        raise ValueError(f"DNS resolution failed: {hostname}")

    for family, _, _, _, sockaddr in addrs:
        ip = ipaddress.ip_address(sockaddr[0])

        # Check against blocked ranges
        for blocked in BLOCKED_RANGES:
            if ip in blocked:
                raise ValueError(f"IP {ip} is in blocked range {blocked}")

        # Return first valid IP
        return str(ip)

    raise ValueError(f"No valid IP addresses for {hostname}")

3. DNS Pinning

Once the resolver returns an IP for a hostname, that IP must hold for the duration of the request. Do not let the HTTP library re-resolve:

import http.client
import ssl

def fetch_with_dns_pinning(url: str, validated_ip: str) -> bytes:
    parsed = urllib.parse.urlparse(url)
    hostname = parsed.hostname

    # Connect to the validated IP, not the hostname
    conn = http.client.HTTPSConnection(
        host=validated_ip,
        port=parsed.port or 443,
        context=ssl.create_default_context(),
    )

    # Set Host header and SNI to original hostname
    conn.request(
        "GET",
        parsed.path + ("?" + parsed.query if parsed.query else ""),
        headers={"Host": hostname},
    )

    response = conn.getresponse()
    return response.read()

4. Egress Proxy Architecture

The agent should never make direct HTTP requests. All outbound traffic must flow through a dedicated egress proxy:

Agent Runtime
    ↓ (URL fetch request)
Egress Proxy
    ├── URL canonicalization
    ├── Scheme allowlist (http, https only)
    ├── Domain allowlist (per-tenant configurable)
    ├── DNS resolution and IP validation
    ├── CIDR blocklist check
    ├── DNS pinning (connect to validated IP)
    ├── TLS hostname pinning (SNI = original hostname)
    ├── Response size limiting
    ├── Content-Type allowlisting
    └── Response sanitization (strip metadata-looking content)
    ↓
External Internet

The proxy enforces all SSRF protections at the network layer. The agent's code cannot bypass the proxy — the network policy blocks direct outbound connections from the agent container.

5. Per-Tenant URL Allowlists

In a multi-tenant AI platform, each tenant configures which domains their agents can fetch:

tenant_url_policy:
  tenant_id: "tenant-456"
  mode: allowlist  # or blocklist
  allowed_domains:
    - "api.openai.com"
    - "api.anthropic.com"
    - "*.company.com"
  blocked_domains:
    - "internal.company.com"
  max_response_size_bytes: 1048576  # 1 MB
  allowed_content_types:
    - "text/html"
    - "application/json"
    - "text/plain"
  timeout_seconds: 10
  max_redirects: 3
  block_cloud_metadata: true
  block_private_ranges: true

6. Redirect Handling

HTTP redirects are an SSRF bypass vector. An attacker can redirect from an allowed domain to an internal IP:

  1. Agent fetches https://attacker.com/redirect (passes allowlist)
  2. Server responds with 302 Location: http://169.254.169.254/...
  3. Agent follows redirect to internal endpoint

Defense: Validate every redirect target through the same canonicalization, IP resolution, and CIDR check pipeline. Do not skip validation for redirects.

def fetch_with_redirect_validation(url: str, max_redirects: int = 3) -> bytes:
    current_url = url
    for _ in range(max_redirects):
        canonical = canonicalize_url(current_url)
        ip = resolve_and_validate(canonical)
        response = fetch_with_dns_pinning(canonical, ip)

        if response.status in (301, 302, 303, 307, 308):
            redirect_url = response.headers.get("Location")
            if not redirect_url:
                break
            # Validate redirect scheme
            redirect_parsed = urllib.parse.urlparse(redirect_url)
            if redirect_parsed.scheme not in ('http', 'https'):
                raise ValueError(f"Redirect to non-http scheme blocked: {redirect_url}")
            current_url = redirect_url
            continue

        return response.read()

    raise ValueError("Max redirects exceeded")

7. Response Sanitization

Even with URL validation, the response content may contain sensitive data. Add response-level defenses:

  • Size limiting: Cap response size (e.g., 1 MB) to prevent resource exhaustion
  • Content-Type allowlisting: Only allow safe content types (text, JSON, HTML)
  • Metadata pattern detection: Scan responses for patterns that look like cloud metadata (IAM credentials, instance IDs) and redact them
  • Secret detection: Run the response through a secret scanner before returning it to the agent's context

Common Implementation Mistakes

Hostname-Based CIDR Checks

Checking the hostname string against a blocklist without resolving it first. This fails immediately for DNS rebinding and for hostnames that resolve to private IPs.

Checking Only IPv4

Blocking 127.0.0.1 but not ::1 or ::ffff:127.0.0.1 or fd00:ec2::254. The blocklist must cover both IPv4 and IPv6, including mapped addresses.

Regex on Raw URL Strings

Using regex to check for 127.0.0.1 in the URL. This misses every numeric encoding variant (decimal, hex, octal, mixed). Canonicalize first, then check.

Letting the HTTP Library Handle DNS

Calling requests.get(url) and hoping the library validates the IP. It does not. The library resolves the hostname and connects — no validation. You must resolve, validate, and pin the IP yourself.

Not Validating Redirect Targets

Validating the initial URL but blindly following redirects. An attacker redirects from an allowed domain to an internal IP. Every redirect must go through the same validation pipeline.

Allowing the Agent Direct Network Access

Running the agent in a container with unrestricted outbound network access. Even if the agent's code has SSRF protections, a prompt injection that causes the agent to shell out to curl bypasses all of them. Network policy must block direct outbound access from the agent container — only the egress proxy is allowed outbound.

Testing Your SSRF Defenses

Test Cases

Test Input Expected Result
Cloud metadata (IPv4) http://169.254.169.254/latest/meta-data/ Blocked
Cloud metadata (IPv6) http://fd00:ec2::254/latest/meta-data/ Blocked
Localhost http://127.0.0.1/ Blocked
Localhost (IPv6) http://[::1]/ Blocked
IPv4-mapped IPv6 http://[::ffff:127.0.0.1]/ Blocked
Decimal IP http://2130706433/ Blocked
Hex IP http://0x7f000001/ Blocked
Octal IP http://0177.0.0.1/ Blocked
Private range http://10.0.0.1/ Blocked
DNS rebinding http://rebind.attacker.com/ Blocked (IP changes between resolve and connect)
File scheme file:///etc/passwd Blocked (scheme not allowed)
Redirect to internal http://attacker.com/redirect-to-metadata Redirect blocked
Allowed domain https://api.openai.com/v1/models Allowed

Automated Red Teaming

Use tools like Promptfoo's SSRF plugin or DeepTeam's SSRF red-teaming to automatically test whether your agent can be manipulated into fetching attacker-chosen resources:

  • internal_service_access: Tests whether the agent prevents unauthorized access to internal services
  • cloud_metadata_access: Tests whether the agent blocks access to cloud metadata endpoints
  • port_scanning: Tests whether the agent prevents port scanning attempts

Implementation Checklist

  • [ ] Canonicalize all URLs before any policy check (decode, normalize IPs, strip fragments)
  • [ ] Enforce scheme allowlist (http and https only)
  • [ ] Resolve hostnames yourself and validate IPs against CIDR blocklist
  • [ ] Block all private CIDR ranges (IPv4 and IPv6, including mapped addresses)
  • [ ] Block cloud metadata endpoints (169.254.169.254, fd00:ec2::254)
  • [ ] Implement DNS pinning — connect to validated IP, do not re-resolve
  • [ ] Validate every redirect target through the same pipeline
  • [ ] Route all agent HTTP traffic through an egress proxy
  • [ ] Block direct outbound network access from agent containers (network policy)
  • [ ] Implement per-tenant URL allowlists
  • [ ] Cap response size and timeout
  • [ ] Allowlist response Content-Types
  • [ ] Scan responses for secrets and metadata patterns
  • [ ] Reject embedded credentials in URLs
  • [ ] Test with automated SSRF red-teaming tools
  • [ ] Test all numeric IP encoding variants
  • [ ] Test DNS rebinding scenario
  • [ ] Test redirect-to-internal scenario
  • [ ] Log all URL fetch attempts with resolved IP and validation result
  • [ ] Alert on blocked SSRF attempts (indicates prompt injection or tool poisoning)

Conclusion

SSRF is one of the most dangerous attack vectors for AI agents. An agent with URL-fetching tools running in a cloud environment is one prompt injection away from leaking IAM credentials, accessing internal services, or scanning your network. The Capital One breach demonstrated what happens when SSRF reaches cloud metadata — for AI agents, the attack surface is wider and the entry points are more varied.

The defense is layered: canonicalize URLs before any check, enforce scheme allowlists, resolve and validate IPs against comprehensive CIDR blocklists, pin DNS to prevent rebinding, validate redirect targets, and route all traffic through an egress proxy. The agent must never make direct network requests — network policy must enforce this at the infrastructure layer.

Test your defenses with automated red-teaming. The test cases are known: cloud metadata, private ranges, numeric IP encodings, DNS rebinding, redirect-to-internal. If any of these reach an internal endpoint, you have a vulnerability that a prompt injection can exploit.