--- 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.