Harden a capability tool's outbound requests¶
Most capability tools take input and return output — a pure function, or a standing connection to one provider's fixed API. This guide is for the other kind: a tool that makes an outbound HTTP request to a URL the user supplied. That URL is attacker-controlled input, so the tool has to defend its own egress before it dials.
Three tools on the platform are this shape today —
hadrontool-webfetch,
hadrontool-home-assistant,
and hadrontool-mcp
— and they share the guard described below. The reference
implementation is the SSRF fix in
hadrontool-home-assistant#2;
link to it rather than re-deriving the code.
When you need this¶
Apply this guide when your tool issues a request to a caller-supplied destination — a URL the user registered, typed, or pasted, that your tool then fetches.
You do not need it when the outbound request always goes to your
provider's fixed endpoint. A tool that only ever calls
https://api.twilio.com has no user-controlled destination and no SSRF
surface; the host is a constant in your code, not an input. The moment
the host comes from the caller, every defense below applies.
The threat is Server-Side Request Forgery (SSRF): your tool runs
inside the private network, so a caller who can aim it at
http://169.254.169.254/ (a cloud metadata endpoint) or
http://hadrontool-pdf:8080/ (a peer service) is borrowing your
tool's network position to reach things they can't reach themselves.
1. Refuse private address space¶
Resolve the destination host to IP addresses and reject the request if any resolved address is private, loopback, link-local, or otherwise reserved. Classify the actual address bytes — not the string:
new URL()canonicalizes the host.http://0x7f.1/andhttp://[::ffff:127.0.0.1]/both name loopback; a regex on the dotted-quad string sails right past them. Resolve the host, then test the decoded bytes of each returned address.- Reject if any address in the result set is bad, not just the first. A hostile name can resolve to one public and one private address; you fetch whichever the connect path picks, so all of them must be clean.
Reserved ranges to reject include IPv4 loopback (127.0.0.0/8), private
(10/8, 172.16/12, 192.168/16), link-local (169.254/16, which
covers cloud metadata), and the native IPv6 equivalents (::1,
fc00::/7, fe80::/10).
Embedded-IPv4 forms decode to their IPv4, then inherit its class —
they are not a range to blanket-reject. An IPv4-mapped address
(::ffff:0:0/96), an IPv4-compatible address (::/96), or a NAT64
address (64:ff9b::/96) carries a real IPv4 in its low 32 bits: extract
those bytes and run them through the IPv4 classifier. ::ffff:127.0.0.1
is loopback and refused; ::ffff:8.8.8.8 is public and allowed.
Rejecting the whole ::ffff:0:0/96 block instead would false-positive
every public host reached over IPv4-mapped v6.
Self-hosted deployments sometimes need to reach a LAN device — a Home
Assistant box at 192.168.1.50 is the whole point of that tool. Gate
that on an explicit, per-tool opt-in env flag (<TOOL>_ALLOW_PRIVATE_NETWORKS)
that defaults to off. The hosted platform leaves it unset, so the
guard is fully strict there; only an operator running their own instance
against their own LAN turns it on.
2. Require https for public upstreams¶
A tool that carries a long-lived credential — a bearer token, an API
key — must never send it over a public network in cleartext. Require
https: for any public host.
Permit plain http: only when <TOOL>_ALLOW_PRIVATE_NETWORKS is set,
i.e. only for the LAN case where there's no public path to sniff and a
local device may not speak TLS. Public host over http: is always a
refusal.
3. Don't follow redirects¶
Set redirect: 'manual' on the fetch. You validated the registered
destination; a 302 to http://169.254.169.254/ was never checked and
would walk your pinned, cleared request straight to a forbidden target.
Treat a redirect as a non-answer — surface it as an error, don't chase
it.
4. Pin the validated address (close the DNS-rebinding window)¶
This is the core of the guide, and the part that's easy to get subtly
wrong. Steps 1–3 validate at check time. But the ordinary
resolve-then-fetch() pattern re-resolves the host at connect time —
a second DNS lookup you don't control. A hostile nameserver answers
public during your check and private at connect. Your guard
passed; your socket lands on 127.0.0.1. That gap is a
DNS-rebinding TOCTOU (time-of-check to time-of-use).
Close it by pinning: resolve once, validate, then force the connection to use only those validated addresses — never re-resolving.
- Have your guard return the validated addresses (undici's
LookupAddress[]), not just a boolean. - Route the fetch through a per-request
undici.Agentwhoseconnect.lookuphands back exactly those pinned addresses. - Pass that agent as the
dispatcher— globalfetchhonors it.
import { Agent } from 'undici';
import type { LookupAddress } from 'node:dns'; // NOT exported by undici — it's Node's DNS type
import type { LookupFunction } from 'node:net';
// `pinned` is the validated LookupAddress[] your guard returned.
const agent = new Agent({
connect: {
// undici calls lookup with { all: true } and expects the ARRAY form.
lookup: ((_hostname, _opts, cb) => cb(null, pinned)) as unknown as LookupFunction,
},
});
try {
const res = await fetch(url, { dispatcher: agent, redirect: 'manual' });
const body = await res.text(); // fully consume the body BEFORE the finally runs
// … enforce the byte cap while reading (see step 5) …
} finally {
// Fire-and-forget once the body is fully read; don't await it.
void agent.close();
}
The gotchas, each verified against the reference implementation:
- Return the array, not a scalar. undici invokes the lookup with
{ all: true }and expectsLookupAddress[]. Hand it a single value and the connection fails withInvalid IP address: undefined. - Import
LookupAddressfromnode:dns, not undici. undici exportsAgentbut notLookupAddress— that's Node's DNS lookup-result type. Copying it fromundicifails to compile. - The cast is load-bearing. undici's
lookupsignature is narrower than Node'sLookupFunction(fromnode:net); cast withas unknown as LookupFunctionso TypeScript accepts the pinned callback. - Only the connect IP is pinned. The
Hostheader and TLS SNI still derive from the hostname, so certificate validation is intact — you've changed where the socket connects, not who the server claims to be. - Consume the body before you close the agent. Closing the agent
tears down the connection, so a body still being streamed is aborted
mid-read. Read it fully inside the
try(e.g.await res.text()), thenvoid agent.close()in thefinally. In particular, don't return the liveResponseup the stack and close the agent here — the caller gets a dead stream. Read first, then hand back the decoded bytes.
5. Bound the response body¶
A hostile or broken upstream can answer with an unbounded stream and
exhaust your tool's memory. Read the body under a byte cap and abort once
it's exceeded — stream and count, or check Content-Length and then
enforce the cap while reading (a lying Content-Length is why you
enforce while reading, not just up front).
Testing note¶
The pinned path can't be loopback-integration-tested under the default
(strict) posture: step 1 blocks 127.0.0.1 before the pin ever
applies, so you can't stand up a local server and fetch it through the
guard as-is. Two ways around it:
- End-to-end — flip
<TOOL>_ALLOW_PRIVATE_NETWORKSon in the test environment. That lets the guard pass a loopback address so you can exercise the whole flow, pin included, against a local server. - Unit — cover the pinning logic against a stubbed resolver: assert
the guard returns the expected
LookupAddress[]for a given resolver result, and that the classifier rejects each reserved range (including the IPv4-mapped, IPv4-compatible, and hex-encoded forms — and allows a public IPv4-mapped address like::ffff:8.8.8.8).
Either way, integration-test the refusals (private host → rejected) against real addresses in the strict posture, so the default deployment's guarantee is what's under test.
Checklist¶
- Scope check — the outbound destination is caller-supplied, not a fixed provider endpoint. (If fixed, you don't need this guard.)
- Private-space refusal — resolve the host and reject if any
resolved address is private/loopback/link-local/reserved,
classified on decoded bytes, not the URL string; embedded-IPv4 forms
(
::ffff:…, NAT64) decode to their IPv4 and inherit its class. - LAN escape hatch —
<TOOL>_ALLOW_PRIVATE_NETWORKS, default off; unset on the hosted platform. - TLS for public hosts — require
https:; allowhttp:only under the private-networks flag. - No redirects —
redirect: 'manual'. - Pinned connect — guard returns
LookupAddress[]; per-requestundici.Agentwithconnect.lookupreturning only those addresses; passed asdispatcher; agent closed fire-and-forget. - Bounded body — response read under a byte cap.
- Tests — unit-test the pin against a stubbed resolver (loopback isn't reachable through the strict guard; the escape hatch enables an end-to-end path); integration-test the refusals.
Related¶
- Add a capability tool — the base recipe for the inbound (core → tool) boundary; this guide hardens the outbound (tool → user URL) direction.
- Capability tools — the Hadron-blind boundary these tools sit behind.
- hadrontool-home-assistant#2 — the reference implementation this guide generalizes.