ADR-0058: Hosted tool HTTP may only reach public addresses¶
- Status: Proposed (KAM-232). The guard itself is a security default; the operator allow-list in "Decision" is the one new surface and needs the product owner's sign-off.
- Date: 2026-10-22
- Deciders: Jian (product owner)
Context¶
A spec names the URLs its tools call. Locally that is the developer's own business, and calling localhost is a
feature. On the hosted server the spec is a tenant's, and the server's network position is not the tenant's:
from the server process, http://169.254.169.254/ is the cloud metadata service, http://10.x.x.x/ is the
cluster, http://127.0.0.1:5432/ is its own database. Nothing stopped a tenant tool from calling them. Hosted
runs already get an empty environment (ADR-0056), so the server's secrets are not in the tenant's reach by
name; the network was the remaining way in. It predates spec 1.1 and has to be closed before public signup.
Options¶
- Block by URL text (reject
localhost,127.,10.in the string). Rejected: a name can resolve to a private address,0x7f.1and2130706433are loopback, and IPv6 can wrap an IPv4 address. - Resolve the name, check the addresses, then fetch. Closer, but the fetch resolves again, so a name that answers public to the check and private to the connect (DNS rebinding) gets through. A redirect from a public URL to a private one also gets through.
- Check the address a connection will use, at connect time, and again at every redirect hop. Chosen.
- An egress proxy or network policy only. A good second layer for a deployment, but it is not in the product: a self-hosted install without it would be open. The guard is in the process; a network policy can sit beside it.
Decision¶
createGuardedFetch(packages/server/src/run/ssrf-guard.ts) is the defaultfetchImplof the hosted run manager, so every tool and component call goes through it. Local mode is untouched.- An address is blocked when it is loopback, private (RFC 1918), carrier-grade NAT, link-local (including the metadata address), unspecified, multicast, reserved, documentation, unique-local or site-local IPv6, or an IPv6 address that wraps one of those IPv4 ranges (mapped, NAT64, 6to4). Anything that is not an address is blocked.
- The check runs on a literal IP in the URL, on every address a name resolves to (one private answer among
public ones blocks it), and again in the connection's own DNS lookup (undici
connect.lookup), which is what stops rebinding. A scheme other thanhttpandhttpsis refused. - Redirects are followed by the guard, one hop at a time, each checked before any connection to it (the browser
rules for 303 and POST,
AuthorizationandCookiedropped across origins, at most 5 hops). A caller that asks forredirect: "manual", as the engine does for a request carrying a credential, gets the response back and its next call is checked the same way. - A block is a
ToolCallErrorwith codepermissionthat is not retryable, naming the tool and the host. - Self-host exception:
KAMPONG_EGRESS_ALLOW_HOSTS(comma-separated exact hostnames or IP addresses), oregressAllowHostsin code, lets an operator name internal services a tool may reach. Empty by default, no wildcards and no ranges, so each exception is a deliberate, visible line. - Replacing
fetchImplis a test seam; a replacement is not guarded.
Consequences¶
- Not covered: the model provider call. A hosted tenant's model
base_urlis a separate path (BYOK client inmodel/resolve.ts) and wants the same guard (KAM-234). - Slack's
response_urlin an approval reply comes from a signed Slack request and is not a tenant's URL. - The guard adds one DNS lookup per call. Answers are not cached here; the system resolver's cache does that.
undicibecomes a direct dependency ofpackages/server. It was already in the tree (the same major as Node 24's built-in fetch).