SSRF guard on user-configurable URLs, re-checked on every redirect hop
byob-security.6
httpsecurity
Problem: the moment a config file or flag can supply an endpoint URL
(api_url = "..." in a project-scoped config that byob-config.1
happily loads from any directory), a hostile or shared config can
steer the CLI's HTTP client at targets the user can reach but the
config author shouldn't: localhost admin ports, cloud metadata at
169.254.169.254, RFC 1918 services behind the user's firewall. A
CLI run inside CI with cloud credentials makes this a real
credential-theft vector, not a theoretical one.
Idea: validate every operator-controlled URL at the boundary — after config load, before the client touches it (byob-input-validation.5's boundary discipline applied to URLs) — and again on every redirect hop:
- Scheme allowlist:
http/httpsonly. - Address check: if the host is an IP literal, check it
directly; otherwise resolve it and check every returned
address. Reject loopback, link-local (unicast and multicast —
link-local covers the metadata endpoint), private (RFC 1918),
unspecified (
0.0.0.0), and multicast.net/netiphas a predicate for each of those categories — but the predicates don't exhaust special-purpose address space; add explicit checks for CGNAT (100.64.0.0/10) and benchmarking (198.18.0.0/15) ranges if your threat model includes them. - Redirect layer: the initial-URL check alone is defeated by a
302 — a hostile or MITM'd upstream redirects the validated public
URL to
127.0.0.1and the stdlib client follows it. Setclient.CheckRedirectto re-run the same validation on each hop'sreq.URL. A customCheckRedirectreplaces the stdlib default, so re-add the 10-hop cap yourself. - Escape hatch: self-hosted deployments legitimately point at
private endpoints. Make the opt-out an explicit per-source config
field (
allow_private = true), carried to the redirect layer as a context key on the request — the stdlib propagates the request context across redirect hops, so the opt-in follows the request rather than becoming a process-wide off switch. Name the knob in the rejection message so a legitimate user unblocks themselves in one edit.
Scope the guard to operator-controlled URLs. Hardcoded endpoints don't need it, but leave a comment at the guard site saying new configurable sources must route through it — the policy is easy to silently lose when the next URL field lands.
Tradeoffs: validation resolves DNS at check time, and the dial
happens later — a DNS-rebinding attacker can pass the check and
rebind before connect. Closing that window needs the check inside
the dialer (Transport.DialContext wrapping the resolved address),
which forces the policy onto every request including hardcoded
endpoints; take that trade only if rebinding is in your threat
model. ~60 lines total for the boundary + redirect version.
Design
func ValidatePublicHTTPURL(ctx context.Context, urlStr string) error {
u, err := url.Parse(urlStr)
if err != nil { return fmt.Errorf("parse url: %w", err) }
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme %q is not http or https", u.Scheme)
}
host := u.Hostname()
if host == "" { return errors.New("url has no host") }
if addr, err := netip.ParseAddr(host); err == nil {
return checkPublicAddr(addr, host) // IP literal
}
addrs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil { return fmt.Errorf("resolve %q: %w", host, err) }
if len(addrs) == 0 { // a successful-but-empty answer must not pass:
// the dial later re-resolves independently of this check
return fmt.Errorf("resolve %q: no addresses returned", host)
}
for _, a := range addrs {
if err := checkPublicAddr(a, host); err != nil { return err }
}
return nil
}
func checkPublicAddr(addr netip.Addr, host string) error {
switch {
case addr.IsLoopback(),
addr.IsLinkLocalUnicast(), addr.IsLinkLocalMulticast(),
addr.IsPrivate(), addr.IsUnspecified(), addr.IsMulticast():
return fmt.Errorf(
"refusing %q: resolves to non-public %s (set allow_private = true to override)",
host, addr)
}
return nil
}
// Factory's client wiring — redirect hops re-validated:
client := &http.Client{
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 { // custom hook replaces the stdlib default; restore its cap
return errors.New("stopped after 10 redirects")
}
if allowPrivateFromContext(req.Context()) {
return nil // per-source opt-in, carried on the request context
}
if err := ValidatePublicHTTPURL(req.Context(), req.URL.String()); err != nil {
return fmt.Errorf("redirect blocked: %w", err)
}
return nil
},
}