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

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

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 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): 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 and /connectors/federated.

Credential brokering

Where the egress guard sits in the full path of a tool call.

Gateway overview

The data plane that owns every outbound hop.