AI agents routinely fetch content from URLs provided by users, LLM outputs, or external systems. Yet most agents lack systematic URL validation before making requests. The result: a single malicious link can trigger SSRF attacks, data exfiltration, or credential compromise without user interaction. This article outlines how to build a zero-trust URL pipeline that treats every URL as potentially hostile.
The Zero-Click Attack Surface
In 2023, researchers demonstrated that LLM systems could be manipulated into making requests to attacker-controlled endpoints through carefully crafted prompts. The attack chain was simple: the LLM generated a URL containing sensitive data as query parameters, then "decided" to fetch that URL to "verify" information. The attacker server received the request and extracted API keys or session tokens from the URL itself.
The core vulnerability isn't exotic. When an agent receives a URL from any source—user input, tool output, or another agent's response—it typically passes that URL directly to an HTTP client. No validation, no allowlisting, no inspection. This bypasses every other security control because the request appears to originate from a legitimate internal service.
An attacker-controlled URL can point to internal metadata endpoints (169.254.169.254 on cloud instances), internal services, or localhost ports with admin interfaces. A single unchecked request becomes a network pivot.
Architecture: The Validation Pipeline
A zero-trust URL pipeline separates URL handling into distinct stages: parsing, normalization, policy enforcement, and execution. Each stage can reject a URL and terminate the request. This provides multiple opportunities to catch attacks and clear audit points when something goes wrong.
from urllib.parse import urlparse
import ipaddress
import re
class URLValidator:
def __init__(self, allowed_schemes=None, blocked_hosts=None):
self.allowed_schemes = allowed_schemes or {'https'}
self.blocked_hosts = blocked_hosts or set()
def validate(self, url: str) -> ValidationResult:
# Stage 1: Parse
parsed = urlparse(url)
if not parsed.hostname:
return ValidationResult.reject("parse_failed", url)
# Stage 2: Policy enforcement
if parsed.scheme not in self.allowed_schemes:
return ValidationResult.reject("scheme_not_allowed", url)
# Block IP-based URLs
try:
ip = ipaddress.ip_address(parsed.hostname)
if self._is_internal_ip(ip):
return ValidationResult.reject("internal_ip_blocked", url)
except ValueError:
pass # It's a hostname
# Block internal hostnames
if self._is_internal_hostname(parsed.hostname):
return ValidationResult.reject("internal_hostname_blocked", url)
return ValidationResult.accept(url)
def _is_internal_hostname(self, hostname: str) -> bool:
patterns = [r'\.internal$', r'\.local$', r'localhost$', r'\.svc\.cluster\.local$']
return any(re.match(p, hostname) for p in patterns)
def _is_internal_ip(self, ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
return ip.is_private or ip.is_loopback or ip.is_link_local
Handling Redirects and DNS Rebinding
URL validation at request-time isn't sufficient. Attackers use redirects to bypass initial checks: your validator sees https://trusted-site.com/image but the server responds with a 302 redirect to http://169.254.169.254/latest/meta-data/. The HTTP client follows the redirect and exposes cloud credentials.
DNS rebinding attacks exploit the gap between DNS resolution and request execution. The attacker sets a DNS record with a short TTL pointing to a legitimate IP. Your validator checks the URL and sees a benign external address. Between validation and execution, the DNS record updates to point at an internal service.
Defending against these attacks requires re-validation at each redirect hop and pinning DNS resolutions. When following a redirect, treat the new URL as untrusted input and run it through the full validation pipeline.
import socket
class SecureHTTPClient:
def __init__(self, validator: URLValidator):
self.validator = validator
self.session = requests.Session()
self.session.allow_redirects = False
def fetch(self, url: str, max_redirects: int = 3) -> Response:
if max_redirects <= 0:
raise ValidationError("too_many_redirects")
# Validate URL
result = self.validator.validate(url)
if not result.valid:
raise ValidationError(result.reason)
# Resolve and verify IP
parsed = urlparse(url)
resolved_ip = socket.getaddrinfo(parsed.hostname, None)[0][4][0]
ip = ipaddress.ip_address(resolved_ip)
if ip.is_private or ip.is_loopback:
raise ValidationError("resolved_to_internal_ip")
# Make request using resolved IP with Host header
target = f"{parsed.scheme}://{resolved_ip}{parsed.path}"
response = self.session.get(target, headers={'Host': parsed.hostname})
# Handle redirects with re-validation
if response.is_redirect:
return self.fetch(response.headers['Location'], max_redirects - 1)
return response
Implementation Checklist
Building a zero-trust URL pipeline requires these operational controls:
- [ ] Implement staged validation: parse, normalize, enforce policy, execute
- [ ] Block internal IP ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 127.0.0.0/8
- [ ] Re-validate URLs at each redirect hop with full pipeline
- [ ] Resolve hostnames to IPs before request and verify against internal ranges
- [ ] Enforce HTTPS-only for production traffic
- [ ] Log all validation decisions with context (source of URL, rejection reason)
- [ ] Alert on suspicious patterns: multiple rejections, internal metadata attempts
- [ ] Enforce validation at HTTP client level so it cannot be accidentally skipped
The goal isn't perfect security—it's raising the cost of attack high enough that attackers move to easier targets. A well-implemented URL pipeline eliminates entire categories of zero-click attacks against your agents.