The safest way to prevent server-side request forgery in an agent is not to give it a general-purpose URL fetcher. Expose narrow tools such as get_issue(owner, repo, number) or read_document(document_id), resolve those identifiers inside trusted code, and enforce authorization there. If arbitrary URLs are truly required, route every request through one outbound broker that validates the destination at connection time, controls redirects and credentials, applies network egress rules, and treats the response as untrusted input.
SSRF is more than http://localhost. A server-side client may reach private address space, link-local services, cloud metadata endpoints, or non-HTTP schemes; redirects and DNS changes can turn an initially acceptable name into a prohibited destination. OWASP therefore recommends allowlisting when possible, validating both application input and network egress, and disabling automatic redirect following (OWASP SSRF Prevention Cheat Sheet).
Start with a capability, not a URL
Compare these tool surfaces:
fetch_url(url, headers, method, body) # dangerous primitive
get_pull_request(repository_id, pull_request_number) # bounded capability
The first lets model-generated arguments choose a host, protocol, request method, headers, and perhaps a body. It can become a port scanner, credential forwarder, or internal control-plane client. The second lets trusted application code choose the upstream service, credential, route template, and response schema. The agent supplies only identifiers that are separately authorized.
Prefer this order:
- A service-specific tool with a fixed origin and operation.
- A tenant-admin allowlist of exact origins and paths.
- A centrally enforced public-web broker for the exceptional workflow that genuinely needs arbitrary sites.
An allowlist must be semantic, not a substring test. host.endsWith("trusted.example") also accepts nottrusted.example; an exact host match or a dot-delimited subdomain rule is required. Do not accept embedded credentials, user-controlled proxy settings, or user-controlled Host, Authorization, Cookie, or forwarding headers.
Put one broker on every outbound path
An agent can create network traffic through more than its obvious browsing tool. Inventory HTTP libraries, webhooks, image and document processors, package installers, remote MCP servers, browser navigation, DNS lookups, and model-provider features that retrieve URLs. Ensure that none can bypass the same outbound policy.
A robust broker separates policy from transport:
agent tool call
│
▼
canonical request policy
├── scheme/origin/method decision
├── authorization and tenant policy
└── request limits
│
▼
resolver + connection policy
├── resolve every A and AAAA result
├── reject prohibited address classes
└── connect only to the selected approved address
│
▼
egress proxy/firewall ──► public destination
The network control matters because input validation can contain bugs and some libraries make their own follow-up connections. Deny agent workloads direct routes to internal networks and metadata services; permit outbound traffic only through the broker or proxy. Treat a cloud provider's hardened metadata protocol as defense in depth, not permission to leave the path reachable.
Parse once, then validate the parsed fields
Use one standards-conforming URL parser, reject parse failures, and make decisions from its normalized fields. The WHATWG URL Standard defines the parsing and serialization behavior used by modern web-platform implementations (WHATWG URL Standard). Do not validate a raw string with a regular expression and then let a different parser interpret it.
For a public-web tool, a conservative policy is:
- allow only
https:; - reject usernames and passwords in the URL;
- allow only the default port or a short explicit port set;
- normalize the hostname before matching policy;
- reject IP literals unless there is a specific reason to support them;
- set request headers in trusted code; and
- discard fragments, which are not sent in an HTTP request and should not affect authorization.
Never allow file:, ftp:, gopher:, data:, custom application schemes, or Unix-socket syntax in a web-fetch capability. The exact deny set is less important than an allow rule that admits only the protocol the feature needs.
Resolve and check the destination at connection time
Checking the hostname string is not enough. Resolve all IPv4 and IPv6 answers and reject the destination if any answer falls in a prohibited range. At minimum, prohibit loopback, private-use, link-local, unique-local, unspecified, multicast, documentation, benchmark, carrier-grade NAT, and other non-global destinations according to the addressing libraries and platform policy you maintain. RFC 1918 defines the familiar private IPv4 blocks (RFC 1918); IPv6 unique-local space is defined separately by RFC 4193 (RFC 4193). A production implementation should use a well-maintained address-classification library rather than encode only those four ranges.
Handle IPv4-mapped IPv6 addresses and unusual textual forms consistently. An attacker should not be able to express the same address as an integer, shortened IPv4, hexadecimal component, IPv6 mapping, or alternate spelling and receive a different policy decision.
DNS rebinding creates a time-of-check/time-of-use problem: the policy resolver sees a public address, but the HTTP client resolves the name again and connects to a private one. The broker should select an approved address from the checked result and connect to that address without a second uncontrolled resolution, while retaining the original hostname for TLS Server Name Indication and certificate verification. Do not disable TLS verification to make address pinning work. If your client cannot safely bind resolution and connection, put the enforcement in a purpose-built egress proxy that can.
Make redirects new requests
The easiest correct redirect policy is no redirects. If the product must follow them, disable the client's automatic behavior and process each Location as a brand-new request:
- Resolve a relative location against the current URL.
- Parse and validate the new scheme, origin, port, and credentials.
- Resolve and classify every destination address again.
- Remove origin-bound headers such as credentials and cookies.
- Apply a small hop limit and detect loops.
- Reconsider the method according to explicit product policy.
Never copy an Authorization header to a different origin. A public site redirecting to an internal address or credential receiver must fail before the connection.
Limit the request and the response
Destination checks do not prevent resource exhaustion or malicious content. Give each operation:
- a connection timeout and total deadline;
- maximum redirect count;
- maximum compressed and decompressed byte counts;
- an allowed response content-type set;
- streaming limits rather than read-then-check behavior;
- concurrency and per-tenant rate limits; and
- a cancellation path when the agent task stops.
Parse returned data with hardened libraries in an isolated worker when the format is complex. Strip active content before rendering it. Most importantly, label fetched text as untrusted data: a page can contain prompt injection that asks the model to call another tool. Network reachability and semantic trust are separate decisions. The prompt-injection defense guide covers that boundary.
An illustrative policy interface
Keep the policy function deterministic and independently testable:
type OutboundDecision =
| { allowed: true; origin: string; addresses: string[] }
| { allowed: false; reasonCode: string };
async function authorizeOutboundRequest(
rawUrl: string,
actor: ActorContext,
): Promise<OutboundDecision> {
const url = parseWithOneApprovedParser(rawUrl);
if (!url || url.protocol !== "https:") return deny("scheme");
if (url.username || url.password) return deny("userinfo");
if (!actor.policy.allowsOrigin(url.origin)) return deny("origin");
const addresses = await trustedResolver.resolveAll(url.hostname);
if (addresses.length === 0) return deny("unresolved");
if (addresses.some(isProhibitedDestination)) return deny("address_class");
return { allowed: true, origin: url.origin, addresses };
}
This is architecture-level pseudocode, not a complete IP classifier or HTTP client. The transport must consume the authorized address set, apply TLS verification, and enforce the same decision after every redirect.
Test the control as an attacker would
Run the tests in an isolated environment with synthetic endpoints. Cover:
- loopback and private IPv4 addresses;
- link-local and cloud-metadata-like addresses;
- IPv6 loopback, link-local, unique-local, and IPv4-mapped forms;
- decimal, hexadecimal, shortened, and mixed address spellings your parser accepts;
- hostnames with userinfo, trailing dots, case differences, and internationalized labels;
- a public DNS answer that changes to a prohibited address;
- public-to-private and cross-origin redirects;
- redirect loops and long chains;
- oversized, slow, compressed, and mislabeled responses;
- attempts to set
Host, forwarding, proxy, cookie, or authorization headers; and - cancellation, timeouts, and broker failure.
Assert the network effect, not the model's prose. A passing test proves that no prohibited connection occurred even if the model eagerly requested it.
Production checklist
- Replace arbitrary fetch tools with service-specific operations wherever possible.
- Route every agent-controlled network path through one policy broker.
- Use exact origin allowlists and server-managed credentials.
- Parse once and permit only required schemes, ports, methods, and headers.
- Resolve every A and AAAA answer and reject all prohibited address classes.
- Bind the checked DNS result to the connection without weakening TLS.
- Disable redirects or fully revalidate every hop and strip credentials.
- Enforce egress policy outside the application as a second boundary.
- Cap time, bytes, decompression, concurrency, and redirect count.
- Treat response content as hostile data and record policy decisions in the audit trail.
- Test alternate encodings, DNS rebinding, redirects, metadata access, and failure behavior.