Node Egress Contract
Constrain a node’s own outbound HTTP so an allowed hostname reaches only the addresses, hops, and byte budget its contract permits.
On this page
Outbound HTTP that a node makes for itself, from OAuth introspection to SIEM export, runs under an HttpEgressContract. The contract names the schemes and authorities a client may reach, the address classes it refuses, the redirects it follows, and the response bytes it keeps, and a contract-backed client applies all of it at the socket.
The contract covers the process’s own clients: authentication metadata, introspection, adapters, and evidence exporters. Governed tool calls meet the kernel’s Network Guards before dispatch. The contract is an HTTP boundary, so a raw TCP connection, a database driver, or a DNS query opened by a wrapped tool server falls outside it.
The address checks bind to the socket
An allowed hostname can still resolve to a private address, redirect to another origin, or return more bytes than the caller budgeted. The contract-backed client closes each of those paths.
The resolver that checks the address supplies the connect
client_builder_with_contract builds a reqwest client with redirects and proxies off and DNS pinned to the contract-backed resolver. The resolver re-checks the hostname against the allowed authorities, checks every answer’s address class, and hands only passing addresses to the socket. Guard Supply Chain.
HttpEgressContract, with the checks in enforce_url order. URL parsing, header handling, and the A2A and OpenAPI bridge clients are left out; the addresses are illustrative.client_builder_with_contract builds a reqwest::Client with redirects off, proxies off, and DNS pinned to the contract-backed resolver. The returned ContractClientBuilder exposes one knob, .timeout(), so a caller cannot turn redirect following back on or swap the resolver out. When a caller hands send_with_contract a client of its own, a response URL that differs from the request URL is an error naming the misconfiguration.
The binding is tested end to end. send_with_contract_refuses_loopback_resolving_host_before_connect points an allow-listed hostname at a live loopback server, keeps deny_loopback on, receives LoopbackDenied, and confirms the server saw no connection.
A contract is a typed allowlist validated once
A contract carries a tenant egress namespace, the allowed schemes, the allowed authorities, three address-class flags (deny_loopback, deny_link_local, deny_ipv6_ula), a redirect ceiling, and a response byte ceiling. The full struct is under Contract fields.
validate() checks shape: a non-empty trimmed namespace, non-empty scheme and authority sets, a nonzero byte ceiling, every scheme in {http, https}, and every authority already canonical. A zero redirect ceiling is legal and means no redirect is followed. prepare() runs validate() once and returns an immutable PreparedHttpEgressContract; changes to the original struct never reach the snapshot.
The convenience methods on the raw type run prepare() on every call, so a hot dispatch path holds the prepared handle. prepared_contract_keeps_validated_snapshot_after_raw_mutation pins both directions: the prepared handle keeps enforcing the original policy, and the mutated raw contract is refused when reused.
Private addresses are refused whatever the allowlist says
Every IP literal, and every address a domain resolves to, falls into one of five classes. Three are refused when their flag is set; one is refused under every contract.
- Flag-gated.
Loopback(127.0.0.0/8,::1),LinkLocal(169.254.0.0/16,fe80::/10), andIpv6Ula(fc00::/7) are refused underdeny_loopback,deny_link_local, anddeny_ipv6_ula. - Always refused.
PrivateOrSpecialUsecovers RFC 1918, CGNAT, the TEST-NET and benchmark blocks, broadcast, multicast, and the IPv6 documentation range, and refuses withPrivateNetworkDenied. - Global. Everything else passes the class check and still has to match the authority allowlist.
An allow-listed authority never buys a private address: private_ipv4_literal_fails_closed_even_when_declared allow-lists 10.0.0.5 and still receives PrivateNetworkDenied. The cloud metadata endpoint 169.254.169.254 is link-local, refused by the flag that every constructor sets. The names localhost and localhost.localdomain raise LoopbackDenied under the same flag, and an IPv4-mapped IPv6 literal such as [::ffff:10.0.0.5] is decoded and refused as 10.0.0.5.
Read more in Address classes, which lists every member range.
A URL is refused at its first violation
enforce_url checks in a fixed order and stops at the first failure. The redirect ceiling comes before the URL is parsed.
- Redirects.
redirect_chain_lenagainstmax_redirect_chain, orRedirectLimitExceeded { observed, max }. - Parse. The target URL, or
InvalidUrl. - Userinfo. A username or password in the URL, or
UserinfoDenied. - Scheme. The lowercased scheme against
allowed_schemes, orSchemeDenied { scheme }. - Host. A host present, then the class check on an IP literal, or
MissingAuthorityor one of the four class refusals. - Authority. The normalized authority against
allowed_authority_set, orAuthorityDenied { authority }.
For a domain name, step 5 can check only the literal loopback names, so step 6 carries the load: exact set membership over normalized authorities. pricing.larkfield.example.example.net is AuthorityDenied against an allowlist holding pricing.larkfield.example. The scheme’s default port is the one equivalence, in both directions: https://pricing.larkfield.example:443/v1 matches a no-port entry, and https://pricing.larkfield.example/v1 matches pricing.larkfield.example:443.
enforce_url_with_dns runs the six steps, resolves a domain-name host through ToSocketAddrs, and class-checks every returned address. An empty answer is DnsResolutionFailed, and a URL with no explicit or default port is InvalidUrl before the lookup. enforce_url and enforce_attempt check without resolving.
A missing contract refuses the request
Callers hold the contract as Option<HttpEgressContract> so it can be threaded from configuration, and absence refuses. The remote MCP runtime states the obligation on the field itself:
/// Typed HTTP egress contract that gates outbound HTTP from the remote
/// MCP runtime (most prominently the OAuth introspection endpoint).
/// Production deployments must populate this; absence falls back to
/// substrate fail-closed at dispatch.
pub egress_contract: Option<HttpEgressContract>,HttpEgressContract::enforce_required turns a None contract into MissingContract. Each client refuses in its own error type and at its own point, from process startup for the introspection auth mode to every export for the SIEM exporters, so match on the refusal, never on its message text. Three callers type the field as a plain HttpEgressContract: the chio-api-protect proxy state, built from the upstream URL, plus PriceOracleConfig and SettlementChainConfig.
Most contracts are derived in code from the endpoint the operator already named: an allowlist of the configured authorities, deny_link_local and deny_ipv6_ula set, and only the configured URL’s scheme. A PriceOracleConfig or SettlementChainConfig read from a file requires every flag and both ceilings under deny_unknown_fields, so prefer their *_default constructors.
Read more in Missing-contract refusals, which lists where each client refuses.
Contract fields, clients, and constructors
Search for deny_loopback, a refusal such as AuthorityDenied, or a crate such as chio-siem.
Contract fields
pub struct HttpEgressContract {
pub tenant_egress_namespace: String,
pub allowed_schemes: BTreeSet<String>,
pub allowed_authority_set: BTreeSet<String>,
pub deny_loopback: bool,
pub deny_link_local: bool,
pub deny_ipv6_ula: bool,
pub max_redirect_chain: u8,
pub max_response_bytes: u64,
}A scheme token outside http and https is InvalidContract carrying invalid HTTP egress scheme "ftp"; expected "http" or "https", so a contract authorizes neither ws nor a custom scheme. An authority entry is refused for a trailing dot, an embedded /, ?, #, or @, any uppercase byte, a bare trailing colon, a zero-padded port, or an uncompressed IPv6 literal: [2001:0db8::1] is written [2001:db8::1].
A signed child-task route can name an egressContractId. That declaration is checked against route metadata, separately from this runtime contract; Route Plans covers it.
Address classes
classify_ipv4_address and classify_ipv6_address sort every address into one class.
| Class | Members | Refused when | Error |
|---|---|---|---|
Loopback | 127.0.0.0/8, ::1 | deny_loopback | LoopbackDenied |
LinkLocal | 169.254.0.0/16, fe80::/10 | deny_link_local | LinkLocalDenied |
Ipv6Ula | fc00::/7 | deny_ipv6_ula | Ipv6UlaDenied |
Private | RFC 1918 ( 10/8, 172.16/12, 192.168/16), 0/8, CGNAT 100.64/10, 192.0.0/24, the three TEST-NET blocks, benchmark 198.18/15, 240/4, broadcast, IPv4 multicast; IPv6 unspecified, multicast, and 2001:db8::/32 | Always | Private |
Global | Everything else | Never by class | n/a |
enforce_loopback_hostname matches localhost and localhost.localdomain directly. IPv4-mapped IPv6 (::ffff:a.b.c.d) is decoded in both directions and is the one embedding the classifier unwraps; a 6to4, Teredo, NAT64, or IPv4-compatible address classifies by its IPv6 form and passes the class check, including in a DNS answer for an allow-listed hostname. The contract holds no hostname blocklist: metadata.google.internal is refused because the allowlist omits it, and name-based blocking belongs to the internal-network guard.
Dispatch under the reqwest feature
Preflight and connection resolution are separate lookups. enforce_url_with_dns resolves through std::net::ToSocketAddrs, checks the answers, and discards them. When the client connects, its contract-backed resolver performs its own lookup and returns only addresses that pass its checks. The checked preflight answers are not passed through to the socket.
Redirects are replayed by hand so each hop is authorized again. A cross-origin hop carrying a body or a non-idempotent method is refused with a message containing cross-origin redirect method/body denied, asserted for all five redirect statuses against a POST with a body, each test confirming the forbidden target received no request. The refusal arrives as HttpEgressError::InvalidUrl, the variant a malformed URL also returns, with that message. A 307 or 308 whose body cannot be cloned is an error. On a legal hop, should_drop_redirect_header drops headers:
| Header | Dropped when | Kept when |
|---|---|---|
Host | Every hop | Never |
Authorization, Cookie, Proxy- | Cross-origin hop | Same-origin hop |
Content-Length, Content-Type | 303, or a POST-to-GET rewrite on 301 or 302 | 307 or 308 replay |
The byte ceiling is checked against a declared Content-Length, then against a running count after every chunk. A test streams 4 MiB with no Content-Length under a 64 KiB ceiling and confirms the observed count stayed under an eighth of the body. ContractResponse holds the body as one Vec<u8> with no streaming reader, so max_response_bytes also bounds the response-body bytes a request retains in memory.
Resolvers
Three HTTP clients in the workspace each resolve and enforce. Two close the check-to-connect window by making the resolver that answers the connect the one that ran the class check.
| Path | Resolves through | Check before lookup |
|---|---|---|
enforce_ (default features) | Synchronous std:: | None; a policy check the caller runs before its own client connects |
Contract (reqwest-egress) | tokio:: | enforce_: dispatchability again, and the hostname must match an allowed authority’s host |
A2a (chio-, ureq) | ToSocketAddrs inside a ureq::Resolver | None; refusals map to Permission IO errors |
chio-link runs enforce_url_with_dns as a config-time admission check, then dispatches through a pinned client. chio-settle’s per-request validate_rpc_egress_contract calls plain enforce_url, because a config-time lookup would be redundant with the pinned resolver, fragile offline, and open to drift between check and use. The OpenAPI-to-MCP bridge is transport-agnostic: invoke_tool validates before dispatch, hands the URL to a caller-supplied dispatcher closure, refuses any 3xx status, and checks the dispatcher’s reported observed_body_bytes. A host that supplies the dispatcher owns its socket and the DNS rebinding risk with it.
Contract constructors
No chio.yaml block holds an egress contract. Each client builds its own, with its own ceilings, and nothing aggregates them per node.
| Built by | Namespace | Authorities | Redirects | Byte ceiling |
|---|---|---|---|---|
default_ (chio-) | chio- | The one --upstream URL | 4 | 64 MiB |
contract_ (chio-) | Per-adapter constant | The one guard endpoint | 4 | 1 MiB |
registry_ (chio-) | chio- | The one registry authority | 3 | 16 MiB |
remote_ (chio-cli) | remote-mcp-auth:<server_id> | Every configured discovery, introspection, and JWKS URL, plus the issuer when a provider profile or discovery URL is set; None when none is configured | 3 | 1 MiB |
SIEM alert and export paths ( chio-siem) | siem:<prefix>:<authority> | The one exporter or webhook authority | 3 | 1 MiB |
build_ (chio-link) | chio-link | The Pyth Hermes URL plus every chain RPC endpoint | 1 | 4 MiB |
Public settlement RPC ( chio-proof-room, chio-cli) | proof. | The one RPC endpoint | 0 | 1 MiB |
The devnet chain-RPC constructors in chio-anchor and chio-settle and the strict-HTTPS anchor contract in chio-control-plane follow the same pattern with their own numbers. chio-link alone carries a poison-pill fallback: when no authority can be derived, fail_closed_default_egress_contract returns an allowlist of invalid.localhost with a zero redirect ceiling and a one-byte ceiling.
Loopback and link-local relaxation
Every constructor sets deny_link_local, and two lower it: the CLI auth contract and the SIEM alert contract, each only when a configured endpoint is itself link-local. deny_loopback comes off under four different rules.
| Rule | Where | What decides |
|---|---|---|
The configured endpoint is loopback | chio-, chio-, chio-cli, chio-siem | The host in the URL the operator supplied |
The constructor requires loopback | Devnet chain RPC in chio-anchor, chio-settle | A non-loopback RPC URL is refused before the contract is built, then deny_loopback: false is set for that target |
A plain-HTTP registry is allow-listed | chio- | deny_loopback: !http_, membership in allow_; a scheme decision, so any listed host loses loopback denial |
A debug build reads an environment variable | Public settlement RPC in chio-proof-room, chio-cli | cfg!(not(debug_; a release build pins it true |
validate_dispatchable_with_pinned_dns is the build-time gate for the pinned-resolver path: it confirms every allowed authority parses for that resolver. chio-api-protect, chio-external-guards, chio-anchor, chio-settle, chio-link, and chio-control-plane call it; chio-guard-registry, chio-cli, and chio-siem call plain validate().
Test contracts
HttpEgressContract::permissive_for_tests turns all three class flags off, with a redirect ceiling of 4 and a 64 MiB byte ceiling, so a wiremock or local server on 127.0.0.1 is reachable. Dependent crates need it, so it is public, and its doc comment reserves it for tests. All six SIEM exporters expose a public new_plaintext_for_tests or new_with_base_url_for_tests that substitutes permissive_for_tests when the config carries no contract, where new refuses. Search your own wiring for _for_tests.
Missing-contract refusals
| Caller | Refuses at | Result |
|---|---|---|
Remote MCP introspection auth mode ( build_) | Startup, while resolving the auth mode | --auth-; the process exits |
OIDC discovery and JWKS fetch ( fetch_) | Before the client is built | <field> `<url>` requires an Http |
Token introspection ( Introspection) | Each authenticated request | HTTP 502 with token introspection requires an Http |
A2A outbound dispatch and response reads | Each call, before the agent is built | Adapter; the ureq agent drops its contract-backed resolver when the contract is absent, so this refusal comes first |
OpenAPI-to-MCP bridge ( enforce_) | Each dispatch, before it starts | OpenAPI bridge dispatcher requires an Http; a dispatcher that reports no observed_ is refused separately |
SIEM exporters (Datadog, Splunk, Elastic, Sumo Logic, OCSF, webhook) | Construction, then every export | Export; the OCSF exporter requires a contract when its endpoint is non-empty |
Tested behavior
private_ipv4_literal_fails_closed_even_when_declaredandipv4_mapped_private_literal_fails_closed_even_when_declared: a private address is refused even when its authority is allow-listed, and a mapped IPv6 literal is reported in its IPv4 form.send_with_contract_aborts_oversized_chunked_body_mid_stream: a chunked response with noContent-Lengthaborts on a bounded prefix.crates/tooling/chio-conformance/tests/ssrf_external_guard_api_protect_dispatch.rs: thechio api protectproxy answers 502 withupstream error: link-local egress target deniedwhen an upstream redirects to169.254.169.254. A sibling test redirects to a second loopback server, which receives no request; the authority allowlist refuses that hop, since the test’s own upstream is loopback anddeny_loopbackis off.threats/ssrf_via_http_substrate.rsand tenssrf_*negative-conformance files driveLoopbackDenied,LinkLocalDenied,Ipv6UlaDenied,RedirectLimitExceeded, andResponseTooLargeagainst the production enforcement functions. Crate unit tests coverPrivateNetworkDenied.missing_contract_fails_closedinchio-http-core/tests/http_egress_contract.rs:enforce_requiredrefuses aNonecontract.