Chio/Docs
LOGIN · JOIN

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.

Concept chio@5f63e6e

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.

Pinned resolverChoose a path.
loopback deniedprivate deniedconnectpnode clientπcontracthop capuserinfoschemeliteralauthorityDresolvergloballoopback10.0.0.5fhostloopback deniedprivate deniedpnode clientπcontracthop capuserinfoschemeliteralauthorityDresolvergloballoopback10.0.0.5fhost

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.

One outbound request from a node’s own HTTP client under an 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), and Ipv6Ula (fc00::/7) are refused under deny_loopback, deny_link_local, and deny_ipv6_ula.
  • Always refused. PrivateOrSpecialUse covers RFC 1918, CGNAT, the TEST-NET and benchmark blocks, broadcast, multicast, and the IPv6 documentation range, and refuses with PrivateNetworkDenied.
  • 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.

  1. Redirects. redirect_chain_len against max_redirect_chain, or RedirectLimitExceeded { observed, max }.
  2. Parse. The target URL, or InvalidUrl.
  3. Userinfo. A username or password in the URL, or UserinfoDenied.
  4. Scheme. The lowercased scheme against allowed_schemes, or SchemeDenied { scheme }.
  5. Host. A host present, then the class check on an IP literal, or MissingAuthority or one of the four class refusals.
  6. Authority. The normalized authority against allowed_authority_set, or AuthorityDenied { 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:

crates/protocol/chio-mcp-remote/src/remote_mcp/session_core.rsrust
/// 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

Contract fields

crates/protocol/chio-egress-contract/src/lib.rsrust
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.

ClassMembersRefused whenError
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
PrivateOrSpecialUse
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
PrivateNetworkDenied
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:

HeaderDropped whenKept when
Host
Every hop
Never
Authorization, Cookie, Proxy-Authorization
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.

PathResolves throughCheck before lookup
enforce_url_with_dns (default features)
Synchronous std::net::ToSocketAddrs
None; a policy check the caller runs before its own client connects
ContractDnsResolver (reqwest-egress)
tokio::net::lookup_host
enforce_resolver_hostname: dispatchability again, and the hostname must match an allowed authority’s host
A2aContractResolver (chio-a2a-adapter, ureq)
ToSocketAddrs inside a ureq::Resolver
None; refusals map to PermissionDenied 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 byNamespaceAuthoritiesRedirectsByte ceiling
default_upstream_egress_contract (chio-api-protect)
chio-api-protect-upstream
The one --upstream URL
4
64 MiB
contract_for_url (chio-external-guards)
Per-adapter constant
The one guard endpoint
4
1 MiB
registry_egress_contract_for_url (chio-guard-registry)
chio-guard-registry
The one registry authority
3
16 MiB
remote_mcp_auth_egress_contract (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_default_egress_contract (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.public-settlement.rpc
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.

RuleWhereWhat decides
The configured endpoint is loopback
chio-api-protect, chio-external-guards, 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-guard-registry
deny_loopback: !http_allowlisted, membership in allow_http_registries; 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_assertions)) || !optional_bool_from_env("CHIO_TEST_PUBLIC_SETTLEMENT_ALLOW_LOOPBACK_RPC"); 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

CallerRefuses atResult
Remote MCP introspection auth mode (build_remote_auth_mode)
Startup, while resolving the auth mode
--auth-introspection-url requires an HttpEgressContract; substrate fails closed; the process exits
OIDC discovery and JWKS fetch (fetch_identity_provider_json)
Before the client is built
<field> `<url>` requires an HttpEgressContract; substrate fails closed
Token introspection (IntrospectionBearerVerifier)
Each authenticated request
HTTP 502 with token introspection requires an HttpEgressContract
A2A outbound dispatch and response reads
Each call, before the agent is built
AdapterError::InvalidUrl; the ureq agent drops its contract-backed resolver when the contract is absent, so this refusal comes first
OpenAPI-to-MCP bridge (enforce_dispatch_contract)
Each dispatch, before it starts
OpenAPI bridge dispatcher requires an HttpEgressContract; a dispatcher that reports no observed_body_bytes is refused separately
SIEM exporters (Datadog, Splunk, Elastic, Sumo Logic, OCSF, webhook)
Construction, then every export
ExportError::HttpError; the OCSF exporter requires a contract when its endpoint is non-empty

Tested behavior

  • private_ipv4_literal_fails_closed_even_when_declared and ipv4_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 no Content-Length aborts on a bounded prefix.
  • crates/tooling/chio-conformance/tests/ssrf_external_guard_api_protect_dispatch.rs: the chio api protect proxy answers 502 with upstream error: link-local egress target denied when an upstream redirects to 169.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 and deny_loopback is off.
  • threats/ssrf_via_http_substrate.rs and ten ssrf_* negative-conformance files drive LoopbackDenied, LinkLocalDenied, Ipv6UlaDenied, RedirectLimitExceeded, and ResponseTooLarge against the production enforcement functions. Crate unit tests cover PrivateNetworkDenied.
  • missing_contract_fails_closed in chio-http-core/tests/http_egress_contract.rs: enforce_required refuses a None contract.

Next: the checks governed calls meet before dispatch

Node Egress Contract · Chio Docs