Documentation
¶
Overview ¶
Package safehttp provides an SSRF-guarded *http.Client.
Many URL-fetching paths here follow attacker-influenceable URLs — an LLM-emitted fetch_url target, an MCP server URL, an OAuth token endpoint discovered from a remote document — which unguarded can reach the cloud-metadata endpoint (169.254.169.254), loopback, or in-cluster services. Client()'s dialer resolves the host, refuses the request if ANY resolved IP is private/loopback/link-local, then dials the concrete vetted IP so the address checked is the address connected to (closing the DNS-rebinding gap). Redirects are re-validated per hop.
Stdlib-only by design: many packages import it, so it must stay dependency-free.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var BlockedCIDRs = mustParseCIDRs(
"100.64.0.0/10",
"192.0.0.0/24",
"198.18.0.0/15",
"240.0.0.0/4",
"64:ff9b::/96",
"2001:db8::/32",
"100::/64",
)
BlockedCIDRs are the special-use ranges net.IP's own predicates do NOT cover. IsPrivate is RFC1918 (10/8, 172.16/12, 192.168/16) plus the IPv6 ULA and nothing else, so each range below was silently reachable through an SSRF-guarded client.
100.64.0.0/10 is the one with a live exposure rather than a theoretical one. GKE supports it as a pod/service CIDR, so on such a cluster it reaches ANY pod — precisely the in-cluster reach this guard exists to deny — and the per-session NetworkPolicies do not compensate, because their egress IPBlock is 0.0.0.0/0 with no Except clause. It is also the range a Tailscale tailnet uses, and this repo ships a tailscale-authkey identity flow whose sidecar shares the runner pod's network namespace.
Exported so an operator-facing knob can extend it: 100.64/10 is legitimately routable on some networks, and the list is the place to say so rather than a second predicate somewhere downstream.
Functions ¶
func Client ¶
Client returns an *http.Client whose transport dials only non-private, non-loopback, non-link-local destinations, re-validates redirects, and times out after 30s overall. The returned client is safe for concurrent use; callers may copy it and tighten Timeout if they need a shorter deadline.
func GuardHost ¶
GuardHost reports whether host — typically (*url.URL).Hostname(), with no scheme or port — is a destination an SSRF-sensitive admission-time content check must refuse, WITHOUT performing a DNS lookup: an IP literal in a blocked range (see isBlockedIP — loopback, link-local unicast/multicast, which covers the 169.254.169.254 cloud-metadata endpoint, private RFC1918/ULA, and unspecified), "localhost", a metadata/internal-shaped hostname (the bare names "metadata" and "metadata.google.internal", or a ".internal"/".local" suffix — the same classification pkg/platform/oap/oci's registry guard uses for the same threat), or a Kubernetes in-cluster Service DNS name (a ".svc"/".svc.cluster.local"/ ".cluster.local" suffix, or a bare name with no dot at all — the cluster resolver's search-domain expansion tries those before ever treating a name as absolute).
This is a pre-dial, static content check, not a substitute for Client()'s guarded dialer: a hostname that looks public but is DNS-rebound to a blocked address at connect time is caught there, not here — the same division checkRedirect already draws for a redirect hop (an IP literal is checked immediately; a hostname is checked at connect time, so the address vetted is the address actually dialed).
func HardenServer ¶
HardenServer applies streaming-safe slow-loris timeouts to s: a ReadHeaderTimeout and an IdleTimeout. It deliberately leaves ReadTimeout and WriteTimeout untouched (0) so long-lived streaming/SSE responses and large request bodies are never truncated — a caller that serves only short request/response pairs may set those separately. Existing non-zero values are preserved, so a caller can opt into stricter limits before calling.
Types ¶
This section is empty.