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:
- When
allow_privateis on, the check short-circuits toOk(()). - The URL must parse and must have a host.
- A literal IPv4 or IPv6 address is judged directly.
- A domain of
localhostor anything ending in.localhostis rejected by name, without resolving. - Any other domain is resolved with
lookup_hoston 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. - If any resolved address is private, the target is rejected — all answers are judged, not just the first.
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
allow_private_upstreams.unwrap_or(env == "dev"). In any
non-dev environment where the flag is unset, private upstreams are refused.
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
httporhttps, 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 outboundreqwest 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::/96wrapper is unwrapped so the embedded IPv4 is judged rather than the wrapper, and IPv6 must fall inside global unicast2000::/3— an allowlist, so the nextis_globalquirk 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 redirectLocationnever 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.
Related
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.