---
title: "Egress and SSRF"
description: "How the gateway constrains outbound calls: the netguard address policy, pinned resolution, disabled redirects, and bounded response sizes."
---
Every credentialed outbound call the gateway makes is aimed at a URL that
ultimately came from configuration a customer supplied — a model provider base
URL, a connector's base URL, a remote MCP origin, an OAuth token endpoint, a
broker session URL. Server-side request forgery is therefore the standing risk,
and `apps/gateway/crates/obol-gateway/src/netguard.rs` is where it is
constrained.
## The address policy
`check_upstream(url, allow_private)` returns `Ok(())` only when the target may
be dialed:
1. When `allow_private` is on, the check short-circuits to `Ok(())`.
2. The URL must parse and must have a host.
3. A literal IPv4 or IPv6 address is judged directly.
4. A domain of `localhost` or anything ending in `.localhost` is rejected by
name, without resolving.
5. Any other domain is resolved with `lookup_host` on the URL's port (or the
scheme's default, falling back to 443). Resolution failure is a rejection,
and so is an empty answer set.
6. **If any resolved address is private, the target is rejected** — all answers
are judged, not just the first.
An address is private when it is:
| Family | Rejected ranges |
|---|---|
| IPv4 | Loopback, RFC 1918 private, link-local, unspecified, broadcast, and CGNAT `100.64.0.0/10` |
| IPv6 | Loopback, unspecified, unique local, unicast link-local, and any IPv4-mapped address whose embedded IPv4 is itself private |
Link-local coverage is what closes the cloud metadata endpoint
`169.254.169.254`; the test asserts it directly alongside `127.0.0.1`,
`10.1.2.3`, `192.168.1.1`, `localhost`, `[::1]`, and `100.64.0.1`.
## Where the guard is applied
| Call site | What is checked |
|---|---|
| `routes/v1.rs` | Each candidate model provider's `base_url`, per routing candidate |
| `hooks.rs` | The connection target of a tool call — `mcp_remote` URL or `openapi` base URL — immediately before dispatch |
| `routes/credentials.rs` | OAuth token endpoints and catalog broker session URLs, after they are bound to the pinned auth profile |
| `verification.rs` | Readback probe URLs, through `pinned_client` |
A blocked model candidate is skipped (or, if it is the last candidate, fails the
request with `upstream target not allowed`). A blocked tool target terminates the
invocation as `upstream_blocked` in state `Denied` — it is a policy outcome with
a receipt, not a dropped connection.
`worker://` targets are not checked against this policy: they resolve to
`OBOL_CONNECTORS_URL`, which is an internal service address by design.
## Configuration
```bash
# Allow loopback/private/link-local upstreams. Unset means: true only when
# OBOL_ENV=dev.
OBOL_ALLOW_PRIVATE_UPSTREAMS=false
```
The effective value is `allow_private_upstreams.unwrap_or(env == "dev")`. In any
non-dev environment where the flag is unset, private upstreams are refused.
Setting `OBOL_ALLOW_PRIVATE_UPSTREAMS=true` in a production deployment disables
the entire address policy for every call site listed above. It is not set on the
hosted platform. Use it for local development and
[Enterprise self-host](/get-started/self-hosting) testing against a loopback
vendor stub, not in prod.
## Pinned resolution
`check_upstream` validates the addresses a hostname resolved to at check time.
It does not itself pin the socket that `reqwest` later dials, so it does not by
itself defeat DNS rebinding between the check and the connect. Pinned
final-egress resolution is separately tracked work; control's
`apps/control/app/services/targets.py` says the same about its own probe, in as
many words.
`netguard::pinned_client` is the shape that does pin, and verification readbacks
use it:
- The scheme must be `http` or `https`, and the URL must carry no username and
no password.
- The host is resolved once; the answer set must be non-empty, must contain no
multicast address, and (unless private is allowed) no private address.
- Unless private is allowed, the scheme must be `https`.
- The client is built with `resolve_to_addrs(host, &addresses)`, so the socket
goes to exactly the set that was validated.
- Redirects are disabled.
## Redirects are disabled everywhere
Every outbound `reqwest` client in the gateway is built with
`redirect::Policy::none()` — the shared client in `lib.rs` and `state.rs`, the
per-connection MCP relay client, the pinned verification client. The reason is
stated at the `OpenApiExecutor` seam: the SSRF guard checks the URL the executor
sends to, and a followed redirect could carry the injected credential to another
origin. A `3xx` is returned to the caller as a response, never followed.
## Bounded responses and timeouts
A vendor or broker controls its own response, so the gateway bounds what it will
buffer (`obol-mcp/src/bounded_http.rs`):
| Path | Ceiling |
|---|---|
| Remote MCP replies | 16 MiB |
| Worker MCP replies | 256 KiB — four times the 64 KiB selected-JSON budget, leaving room for envelope and protocol framing |
| Selected verification values | 64 KiB, at most 16 levels of nesting |
The adapter enforces the limit before JSON parsing and does not retain
vendor-controlled bodies in errors. Relay sessions carry a 120-second transport
timeout; verification probes carry 25 seconds.
## Control-plane target validation
When a federated completion hands Obol an MCP URL that a catalog broker
produced, control validates it before storing anything
(`apps/control/app/services/targets.py`). That validator is deliberately
scoped and documents its own limits:
- The URL is normalized — IDNA-canonicalized with a required round-trip,
bounded to 2048 bytes and a 253-character hostname, with an explicit empty or
malformed port rejected — and then matched **byte-for-byte against the origins
frozen into the connect session** at handoff time. A later catalog or settings
change cannot widen what a completion accepts.
- Every DNS answer on every hop is address-validated. NAT64's `64:ff9b::/96`
wrapper is unwrapped so the embedded IPv4 is judged rather than the wrapper,
and IPv6 must fall inside global unicast `2000::/3` — an allowlist, so the next
`is_global` quirk fails closed instead of needing a new denial.
- The probe is unauthenticated, uses no ambient proxy configuration, and is
budgeted: at most four requests (the initial one plus three redirects), a
2-second DNS timeout, a 5-second request timeout, and a 20-second whole
validation budget.
- Every rejection raises the same fixed message, `mcp target is not allowed`.
The submitted URL, its path, its hostname, a DNS answer, and a redirect
`Location` never reach an exception, a log line, an audit row, or a response —
distinguishable messages would make the validator an oracle for which
hostnames are reviewed and which internal addresses exist.
This is control-plane hardening. It does not pin the socket the gateway later
dials, and it must not be described as preventing DNS rebinding.
## Broker responses are screened, not trusted
A catalog broker's connect-session response is refused outright if any object at
any depth carries an authority-shaped key — `target`, `policy`, `evidence`,
`receipt`, `idempotency`, `idempotency_key`, `retry`, or `credential`. Exactly
one field is read out of the body. That is invariant 9 enforced at the network
boundary: brokers are vendors, never authority. See
[/security/oauth](/security/oauth) and
[/connectors/federated](/connectors/federated).
## Related
Where the egress guard sits in the full path of a tool call.
The data plane that owns every outbound hop.