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 | |
|---|---|---|
| Authorizes | A hostname string, via an MRN (mrn:agentvisor:http:<host>/<path>) | The resolved IP address the hostname actually points to |
| Denies by default | Every destination the PolicyDomain does not match | A fixed set of sensitive ranges: loopback, unspecified, link-local, private, and CGNAT |
| Who defines the denial | You — the PolicyDomain is the entire ruleset | AgentVisor — the range list is built in and identical in every deployment |
| Loosened by | An allow rule in the PolicyDomain | An entry in proxy.ssrf_allowed_cidrs |
| Denial response | HTTP 403 | HTTP 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
| Category | Ranges |
|---|---|
| Loopback | 127.0.0.0/8, ::1 |
| Unspecified | 0.0.0.0, :: |
| Link-local | 169.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 |
| Private | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 |
| Carrier-grade NAT | 100.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.
| Layer | Checks | Allowlist-aware? | Denial |
|---|---|---|---|
| Guest pre-validation | IP literals written directly in the URL | No | HTTP 400, generic body |
| Host dial-time guard | The resolved address of every destination, literal or hostname | Yes | HTTP 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.
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
| Form | Behavior |
|---|---|
| CIDR range | Parsed 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 address | Wrapped to a single-address route (/32 for IPv4, /128 for IPv6). |
| Bare hostname | Resolved 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. |
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
| Symptom | Layer | What's actually happening |
|---|---|---|
| HTTP 403 | Policy gate | MPE denied the resource — this is a PolicyDomain decision, unrelated to the SSRF guard |
| HTTP 400, generic body | Guest pre-validation | The 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 body | Host dial-time guard | The 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.1 | Host dial-time guard, batch rejection | The 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 blocked | Client-side proxy bypass | AgentVisor'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" error | Config validation | A comma-separated ssrf_allowed_cidrs entry has leading or trailing whitespace, or isn't a valid CIDR, IP address, or hostname |
Next Steps
- See the Configuration Reference for the full
proxy.ssrf_allowed_cidrsfield reference and a complete worked example. - See HTTP Proxy for how the proxy fits into the overall outbound request path.
- See Policy Enforcement for the policy gate that runs alongside the SSRF guard.
- See the Troubleshooting Guide if a request is being denied unexpectedly.