ADR-037: SafeFetch denies private ranges by default; opt-out per call
Status: Accepted Date: 2026-04-21
Context
Distill's YouTube handler takes a user-supplied URL and dereferences it server-side. So do (or will) summarisers, feed fetchers, OEmbed integrations, and any future "paste-a-link" workflow. Every one of these is an SSRF primitive waiting to be weaponised:
- An attacker who can POST a URL can enumerate internal services through the pod network.
169.254.169.254is the AWS/GCP/Azure metadata endpoint — fetching from it on a container with instance-role credentials leaks them.- Cloud-hosted databases are usually reachable on
10.0.0.0/8; an attacker who can GET them may be able to dump via error messages. - IPv6 opens further private ranges (
::1,fc00::/7,fe80::/10).
The naive mitigation — "reject URLs on the private-IP list before
calling http.Get" — misses DNS rebinding and open redirects on
public hosts. The correct mitigation is: parse, resolve, vet every
hop, and re-vet on redirects.
Decision
pkg/security.SafeFetch(ctx, rawURL, opts...) is the only approved
primitive for dereferencing user-supplied URLs from gyrum services. It
applies three layers of defence:
- Scheme allow-list. Only
httpandhttps.file://,ftp://,gopher://,javascript:and friends are rejected withErrScheme. - DNS-then-deny. Host is resolved via
net.LookupIP; every A/AAAA answer is checked against a deny list (loopback, RFC1918, link-local, unique-local, multicast, unspecified, and IPv6 equivalents). Any hit returnsErrPrivateIP. - Re-check on redirect.
http.Client.CheckRedirectre-runs the entire validation on every hop up to five hops (overridable).
The opt-out knob is AllowList(hosts...) — a per-call narrowing, not a
per-call relaxation. A caller who pins to api.openai.com is
STILL subject to the private-IP deny (a compromised DNS record for
api.openai.com pointing to 10.0.0.5 is rejected).
The test-only allowLoopbackForTest() helper is unexported: production
code cannot opt out of the loopback deny.
Consequences
Easier:
- Every consumer that would otherwise reach for
http.Geton user input has a drop-in safer alternative. - Canonical errors mean callers can branch on
errors.Is(err, security.ErrPrivateIP)to produce useful UI messaging ("URL must be publicly reachable") without parsing strings. - Redirect checking survives the most common bypass (public host 302s to private IP).
Harder:
httptest.NewServerbinds to 127.0.0.1, so every test usingSafeFetchagainst a test server has to go through an internal test hook. Intentional; avoids a production flag that could be misused.- A service genuinely needing to fetch from an internal allowlist
(e.g. a pod-local sidecar) cannot use
SafeFetch. Such services must reach forhttp.Clientdirectly and document WHY the deny is inappropriate — with an explicit review.
Signed up to operate:
- The deny list is static code. New private ranges (e.g. IPv6 carrier-
grade NAT
fc00::/7as unique-local is already covered; future RFC updates may add more) require a release. - Security standards doc mandates
SafeFetchfor any handler taking a URL from user input. Code review gates on this.
Alternatives considered
- Deny list as configuration — rejected. Config drift is a classic source of security regressions. Ranges change rarely; a code change with an ADR is an appropriate cadence.
- Explicit allow-list only (no default deny) — rejected. Every new integration would need an allow-list entry; missing entries would be rejected loudly, so "fail safe" in one sense — but the allow-list becomes a mis-specified mess, and common cases (fetching arbitrary public pages for summarisation) don't have a natural allow-list at all.
- Resolve the host and call
http.Getwith anIPAddrdirectly — rejected. Many CDN hosts route based on SNI/Host header; substituting an IP breaks TLS certificate validation and HTTP virtual hosting. The CheckRedirect approach preserves host-based routing while still validating each hop.
Supersedes: none Superseded by: —