Skip to main content

Egress Controls

Every outbound HTTP request an agent makes passes through two independent gates before it reaches its destination: a policy gate that authorizes who may reach what, and an always-on SSRF guard that blocks the request from reaching an internal or otherwise dangerous address regardless of what policy says. Both gates sit in the request path unconditionally — there is no configuration that removes either one, only configuration that loosens what each one denies. This page explains how the two relate, and documents the SSRF guard in full.

Two Gates, Not One​

Policy Gate (MPE)SSRF Guard
AuthorizesA hostname string, via an MRN (mrn:agentvisor:http:<host>/<path>)The resolved IP address the hostname actually points to
Denies by defaultEvery destination the PolicyDomain does not matchA fixed set of sensitive ranges: loopback, unspecified, link-local, private, and CGNAT
Who defines the denialYou — the PolicyDomain is the entire rulesetAgentVisor — the range list is built in and identical in every deployment
Loosened byAn allow rule in the PolicyDomainAn entry in proxy.ssrf_allowed_cidrs
Denial responseHTTP 403HTTP 400 (guest layer) or HTTP 502 (host layer)

Both gates are default-deny, and both are loosened the same way — by an operator writing configuration. The difference is who authors the denial, not how firmly it is held. A PolicyDomain is yours to write, so the policy gate is exactly as strict as you make it and denies nothing until you say so. The guard's blocked ranges are built in: the same in every deployment, in effect before an operator configures anything, and the guard itself sits in the request path unconditionally — there is no setting that removes it, only proxy.ssrf_allowed_cidrs to exempt named destinations from it.

Both gates must pass. An MPE allow on a resource does not exempt it from the SSRF guard, and an entry in ssrf_allowed_cidrs grants no policy permission by itself. Reaching a legitimately internal upstream — a self-hosted vector database, an internal model gateway — requires satisfying both. See Policy Enforcement for the policy gate in detail.

Why Policy Alone Cannot Do This​

It's tempting to write an MPE deny-selector for internal address ranges — a resource selector matching mrn:agentvisor:http:10\..*, mrn:agentvisor:http:192\.168\..*, and similar — and treat that as SSRF protection. This does not work, and cannot be made to work with this technique. An MRN is built from the URL host string, evaluated before any DNS lookup happens. A hostname like harmless.example.com that happens to resolve to 169.254.169.254 — a cloud metadata endpoint — sails past every one of those selectors untouched, because nothing about the string harmless.example.com looks like an internal address.

The SSRF guard exists precisely because policy operates one layer too shallow for this class of check. It authorizes the resolved address, not the hostname text, and it does this by default: an operator does not write anything for this protection to take effect.

The SSRF Guard​

What Is Blocked by Default, and What Is Not​

CategoryRanges
Loopback127.0.0.0/8, ::1
Unspecified0.0.0.0, ::
Link-local169.254.0.0/16 (includes the 169.254.169.254 cloud metadata address), fe80::/10, link-local multicast 224.0.0.0/24 and ff02::/16
Private10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
Carrier-grade NAT100.64.0.0/10

Stated honestly, so the boundary is clear: general multicast beyond link-local scope, broadcast (255.255.255.255), IPv6 site-local (fec0::/10), the benchmarking range (198.18.0.0/15), and the TEST-NET ranges are not blocked. The guard's default set targets addresses that reach back into a private network or cloud metadata service — not a general-purpose IP firewall.

Where It Applies​

The guard covers every outbound path through the proxy: unary requests, streaming (SSE) requests, CONNECT tunnels, and protocol upgrades (WebSocket). Redirects are never automatically followed, so a 3xx response pointing at a blocked address cannot be used to reach it after the fact.

The guard does not apply to the MCP Gateway or the A2A Gateway. Their destinations come from operator configuration (the server/agent list in agentvisor.yaml), not from agent-supplied input — the threat the guard defends against (an agent steering the proxy toward an address of its own choosing) doesn't apply to a destination the operator chose themselves. A self-hosted MCP server on a private address needs no ssrf_allowed_cidrs entry. The allowlist matters only for an agent's own outbound HTTP traffic through the proxy — including the case where an agent reaches an MCP server through the proxy rather than through the gateway. See Gateways for the MCP/A2A connection model.

Two Enforcement Layers, and the Asymmetry​

Practical rule: address an internal upstream by hostname, not by IP literal. The guard is implemented in two layers, and only one of them is aware of the allowlist.

LayerChecksAllowlist-aware?Denial
Guest pre-validationIP literals written directly in the URLNoHTTP 400, generic body
Host dial-time guardThe resolved address of every destination, literal or hostnameYesHTTP 502, empty body

The guest-side check runs before a request ever leaves the sandbox and rejects a blocked IP literal unconditionally — it has no visibility into proxy.ssrf_allowed_cidrs at all. So an allowlist entry for, say, 10.0.5.0/24 does not make http://10.0.5.7:8080 work if written as a literal address; the same destination reached as http://internal-service:8080 does work, because a hostname is only evaluated by the allowlist-aware host layer, against the address it actually resolves to.

warning

An allowlisted CIDR does not help a request that names that address literally in the URL — the check that rejects an IP literal runs before the allowlist is ever consulted. Use a hostname for any destination you intend to allowlist.

One address is a further, narrower exception worth knowing about: AgentVisor sets NO_PROXY=127.0.0.1 in every agent's own environment (alongside HTTP_PROXY/ HTTPS_PROXY), and standard proxy-aware HTTP clients (Python's requests, httpx, curl, and effectively every other proxy-respecting client) honor NO_PROXY by connecting directly, bypassing the proxy entirely, for a request whose host is the literal string 127.0.0.1. Such a request never reaches the guest pre-validation, the host SSRF guard, or policy — it isn't blocked or allowed by any of them, because it never arrives. This has been observed under --sandbox=none, where the bypassed connection reaches the host's own real loopback interface. Under gVisor and Docker sandboxing the guest has its own isolated network namespace, so 127.0.0.1 inside the guest resolves to the guest's own loopback rather than the host's; the practical exposure is expected to be confined to unsandboxed local development, though this specific interaction has not been independently confirmed under those sandbox modes.

DNS Rebinding and Batch Rejection​

The host-layer guard resolves a hostname destination itself, checks every resulting candidate address against the blocklist and allowlist, and then dials the resolved IP literally — there is no second, independent lookup between the check and the connection, so there is no window for a DNS answer to change between them.

If any resolved candidate is blocked, the entire dial is rejected — the guard does not skip just the blocked candidates and connect to whichever one is left. This is deliberate: a hostname that resolves to a mix of public and internal addresses is exactly the shape a rebinding attack produces over repeated lookups, and there's no way to tell which of a mixed answer a legitimate caller actually intended.

This batch-rejection rule is also the mechanical cause of a trap operators hit with localhost: a dual-stack resolver commonly returns ::1 before 127.0.0.1 (or vice versa, depending on the host), so allowlisting only 127.0.0.1/32 still leaves ::1 blocked — and because the whole batch is rejected when any candidate is blocked, the request fails even though one of the two addresses was allowed. List both address families, or use the hostname form described below, which resolves once and allowlists everything it finds.

Granting an Exemption​

proxy.ssrf_allowed_cidrs accepts three entry forms, which can be mixed freely:

proxy:
ssrf_allowed_cidrs:
- "10.0.5.0/24" # CIDR range
- "127.0.0.1/32" # bare IP, wrapped to a /32 route
- "::1/128" # bare IP, wrapped to a /128 route
- "localhost" # bare hostname — equivalent to the two loopback CIDRs above
FormBehavior
CIDR rangeParsed and used as-is. Note that a host bit set outside the mask is silently dropped — 10.0.5.7/24 becomes the CIDR 10.0.5.0/24, not a single-address route for 10.0.5.7.
Bare IP addressWrapped to a single-address route (/32 for IPv4, /128 for IPv6).
Bare hostnameResolved once, at startup, and every address it resolves to is added to the allowlist. It is never re-resolved — a DNS change for that name requires a restart to take effect.
warning

Keep ssrf_allowed_cidrs entries as narrow as the upstream you actually need to reach. A /0 entry (0.0.0.0/0 or ::/0) is accepted verbatim and exempts every address in that family — an effective off switch for the host-side guard, accepted with no error, no warning, and no startup log line.

The same list is configurable via the comma-separated environment variable AGENTVISOR_PROXY_SSRF_ALLOWED_CIDRS. Entries are not trimmed, so a space after a comma becomes part of the next entry and fails validation at startup:

AGENTVISOR_PROXY_SSRF_ALLOWED_CIDRS="127.0.0.1/32, ::1/128"

produces the invalid entry " ::1/128" (leading space included) and aborts startup with an error naming that exact malformed entry. Write the list with no space after the comma instead.

A hostname entry is only checked for syntax at config-load time — whether it actually resolves is checked later, when the guard is built. An entry that looks like a hostname but isn't a real, resolvable name (for example a malformed IP address) doesn't fail startup: it is skipped with a warning naming the entry, and startup proceeds normally with that one exemption simply not in effect.

Symptom → Cause​

SymptomLayerWhat's actually happening
HTTP 403Policy gateMPE denied the resource — this is a PolicyDomain decision, unrelated to the SSRF guard
HTTP 400, generic bodyGuest pre-validationThe request names a blocked IP address as a literal in the URL; ssrf_allowed_cidrs does not apply here — address the destination by hostname instead
HTTP 502, empty bodyHost dial-time guardThe resolved address of a hostname destination is blocked and not covered by ssrf_allowed_cidrs; the reason is recorded only in the host's own logs, never returned to the agent
A request to localhost still fails after allowlisting 127.0.0.1Host dial-time guard, batch rejectionThe dual-stack trap — the resolver also returned ::1, which isn't allowlisted, and the whole batch is rejected. Allowlist both CIDRs, or use the hostname form
A request to a literal 127.0.0.1 unexpectedly succeeds when it was expected to be blockedClient-side proxy bypassAgentVisor's own NO_PROXY=127.0.0.1 is honored by the agent's HTTP client, so the request never reaches the proxy, the guard, or policy at all (observed under --sandbox=none)
Startup fails with an "invalid entry" errorConfig validationA comma-separated ssrf_allowed_cidrs entry has leading or trailing whitespace, or isn't a valid CIDR, IP address, or hostname

Next Steps​