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/rolelooks 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.254metadata 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 accessgopher://— arbitrary TCP connectionsdict://— DICT protocolftp://— file transferldap://— 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 confusionhttp://evil.com\@good.com/— backslash vs. forward slashhttp://good.com.evil.com/— subdomain vs. domainhttp://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:
- Agent fetches
https://attacker.com/redirect(passes allowlist) - Server responds with
302 Location: http://169.254.169.254/... - 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 servicescloud_metadata_access: Tests whether the agent blocks access to cloud metadata endpointsport_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.