Skip to content

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/ and http://[::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.

  1. Have your guard return the validated addresses (undici's LookupAddress[]), not just a boolean.
  2. Route the fetch through a per-request undici.Agent whose connect.lookup hands back exactly those pinned addresses.
  3. Pass that agent as the dispatcher — global fetch honors 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 expects LookupAddress[]. Hand it a single value and the connection fails with Invalid IP address: undefined.
  • Import LookupAddress from node:dns, not undici. undici exports Agent but not LookupAddress — that's Node's DNS lookup-result type. Copying it from undici fails to compile.
  • The cast is load-bearing. undici's lookup signature is narrower than Node's LookupFunction (from node:net); cast with as unknown as LookupFunction so TypeScript accepts the pinned callback.
  • Only the connect IP is pinned. The Host header 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()), then void agent.close() in the finally. In particular, don't return the live Response up 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_NETWORKS on 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:; allow http: only under the private-networks flag.
  • No redirectsredirect: 'manual'.
  • Pinned connect — guard returns LookupAddress[]; per-request undici.Agent with connect.lookup returning only those addresses; passed as dispatcher; 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.