Documentation
¶
Overview ¶
Package surfguard resolves and classifies network addresses so that code fetching user-supplied URLs cannot be steered into internal, metadata, or otherwise non-public endpoints (SSRF).
The package is layered; use the highest layer that fits:
- Classification: Policy.Blocked, Policy.BlockedHost judge a single address or host string. Pure, no I/O.
- Resolution: Policy.ResolvePublicAddrs, Policy.CheckURL resolve a host and judge every answer. Legacy numeric spellings (0x7f000001, 2130706433, 127.1) are classified authoritatively and never sent to DNS; malformed numeric-shaped tokens are refused outright.
- Enforcement: Policy.Control, Policy.ControlContext, Policy.DialContext judge the literal address of every socket connect attempt. This is the layer that defeats DNS rebinding: the address checked is the address connected, with no time-of-check gap, on every redirect hop, Happy Eyeballs race, and connection-pool miss.
- Client: Policy.Client and Policy.Transport return real *http.Client / *http.Transport values wired to the enforcement layer, with proxying disabled and redirects re-validated per hop.
The zero value of Policy is the full default policy. Derivation methods (Policy.IANASpecialUse, Policy.AllowLoopback, Policy.Allow, Policy.Deny, ...) return adjusted copies; calls accumulate.
Policies ¶
The default policy blocks private, loopback, link-local, CGNAT, metadata, benchmarking, documentation, multicast, and reserved IPv4 space (including the Azure WireServer address 168.63.129.16, which sits inside public space); on IPv6 it additionally requires membership in the IANA-allocated global unicast ranges, so unallocated IPv6 space is denied by construction. IPv4 addresses embedded in NAT64/SIIT transition prefixes are decoded and re-checked; IPv4-mapped and IPv4-compatible forms and the NAT64 local-use prefix are refused outright.
Policy.IANASpecialUse additionally blocks every prefix in the checked-in IANA special-purpose registry snapshots, including globally reachable service infrastructure (AMT, AS112, the whole NAT64 well-known prefix) — applied to transition-embedded IPv4 as well, so it cannot be bypassed via IPv6 encoding. Use it for advertised or discovered infrastructure values. A target must never choose its own policy; trusted consumer code chooses it.
Policy tables are generated from the IANA registry snapshots checked in under script/iana in the surfguard repository, shared byte-for-byte with the Ruby implementation and verified by a shared conformance corpus.
What surfguard does not do ¶
DNS rebinding is only defeated at the enforcement layer: callers using bare classification or resolution must pin the returned addresses at connection time. There is no proxy support (Policy.Transport sets Proxy to nil deliberately: a proxied request would have the proxy's address validated instead of the target's). International domain names are not converted; punycode-encode before calling.
Index ¶
- Variables
- func Blocked(addr netip.Addr) bool
- func BlockedHost(host string) bool
- func CheckURL(ctx context.Context, rawURL string) error
- func Client() *http.Client
- func ResolvePublicAddrs(ctx context.Context, host string) ([]netip.Addr, error)
- type Policy
- func (p Policy) Allow(prefixes ...netip.Prefix) Policy
- func (p Policy) AllowAllPorts() Policy
- func (p Policy) AllowLoopback() Policy
- func (p Policy) AllowPorts(ports ...uint16) Policy
- func (p Policy) Blocked(addr netip.Addr) bool
- func (p Policy) BlockedHost(host string) bool
- func (p Policy) CheckRedirect(next func(req *http.Request, via []*http.Request) error) func(req *http.Request, via []*http.Request) error
- func (p Policy) CheckURL(ctx context.Context, rawURL string) error
- func (p Policy) Client() *http.Client
- func (p Policy) Control(network, address string, _ syscall.RawConn) error
- func (p Policy) ControlContext(_ context.Context, network, address string, _ syscall.RawConn) error
- func (p Policy) Deny(prefixes ...netip.Prefix) Policy
- func (p Policy) DialContext(ctx context.Context, network, address string) (net.Conn, error)
- func (p Policy) IANASpecialUse() Policy
- func (p Policy) MaxRedirects(n int) Policy
- func (p Policy) ResolvePublicAddrs(ctx context.Context, host string) ([]netip.Addr, error)
- func (p Policy) RoundTripper() http.RoundTripper
- func (p Policy) Transport() *http.Transport
- func (p Policy) WithResolver(r Resolver) Policy
- type Reason
- type Resolver
- type UnresolvableError
- type Violation
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrBlocked = errors.New("surfguard: refusing blocked address")
ErrBlocked is the policy-refusal error family. Every refusal surfguard makes — blocked address, malformed host, refused port, network, scheme, or redirect — matches it via errors.Is, including through the url.Error and net.OpError wrappers added by net/http and net.
var ErrUnresolvable = errors.New("surfguard: host could not be resolved")
ErrUnresolvable reports a lookup that came back empty or unusable. It is deliberately unrelated to ErrBlocked: unresolvable means the target may be retried later; blocked means the target should be refused outright. A caller that deactivates targets on ErrBlocked must not deactivate on ErrUnresolvable.
Functions ¶
func BlockedHost ¶
BlockedHost judges a host string under the default policy without DNS.
func Client ¶
Client returns an *http.Client enforcing the default policy.
Example ¶
The drop-in client: every connection is judged at dial time, every redirect hop is re-validated, proxying is disabled.
package main
import (
"errors"
"fmt"
surfguard "github.com/basecamp/surfguard/go"
)
func main() {
client := surfguard.Client()
_, err := client.Get("http://169.254.169.254/latest/meta-data/")
fmt.Println(errors.Is(err, surfguard.ErrBlocked))
}
Output: true
Types ¶
type Policy ¶
type Policy struct {
// contains filtered or unexported fields
}
Policy is an immutable address policy. The zero value is the full default policy; there is no mutable package-level configuration.
Derivation methods return adjusted copies and accumulate across calls:
fixture := surfguard.Policy{}.IANASpecialUse().AllowLoopback()
Precedence, most to least binding: Policy.Deny > structural defenses (invalid, zoned, IPv4-mapped, IPv4-compatible, and NAT64 local-use addresses are always refused) > the Policy.IANASpecialUse tables > Policy.AllowLoopback and Policy.Allow > the default deny tables and the IPv6 allocated-unicast allowlist. Allow can re-admit space the default tables or the IPv6 allowlist would refuse; it cannot re-admit structural refusals or, except via AllowLoopback, space the IANASpecialUse tables refuse.
func (Policy) Allow ¶
Allow returns a policy that admits the given prefixes where the default deny tables or the IPv6 allocated-unicast allowlist would refuse them. Allow never overrides Deny, structural defenses, or the IANASpecialUse tables. Invalid prefixes panic: allowing is a construction-time decision by trusted code, and a silently dropped allowance would fail closed in a way that masks the bug.
func (Policy) AllowAllPorts ¶
AllowAllPorts returns a policy whose connection layer does not restrict ports. Address policy still applies.
func (Policy) AllowLoopback ¶
AllowLoopback returns a policy that admits 127.0.0.0/8 and ::1 on any port, including under IANASpecialUse. It exists so httptest fixtures (which bind random loopback ports) work against otherwise-strict policies. Never enable it outside tests and tooling that must reach operator-named local targets.
Example ¶
Fixture mode: a test binary talks to httptest loopback servers with a policy that stays strict for everything else.
package main
import (
"fmt"
surfguard "github.com/basecamp/surfguard/go"
)
func main() {
fixture := surfguard.Policy{}.AllowLoopback()
fmt.Println(fixture.BlockedHost("127.0.0.1"))
fmt.Println(fixture.BlockedHost("169.254.169.254"))
}
Output: false true
func (Policy) AllowPorts ¶
AllowPorts returns a policy whose connection layer admits exactly the given ports (accumulating with earlier calls) instead of the default {80, 443}. Loopback targets under AllowLoopback are exempt from port checks. Port 0 panics.
func (Policy) Blocked ¶
Blocked reports whether addr must be refused under the policy. Invalid and zoned addresses fail closed. This is the classification core every other layer delegates to.
IPv4-mapped input (::ffff:a.b.c.d) is blocked outright: a mapped address in classification input is a hostile AAAA record or a caller bug, never a legitimate public target. (The dial layer unmaps before judging instead, because there the kernel really is about to connect to the embedded IPv4.)
func (Policy) BlockedHost ¶
BlockedHost judges a host string without ever querying DNS: address literals (IPv6, dotted-quad, legacy inet_aton spellings, full-width prefixes) are classified; everything else — every hostname included — is reported blocked, because a name's addresses are unknowable without resolution.
A false result therefore means "an admitted address literal", NOT "a safe host": it is never false for a name. Do not use it as an allow-gate for names — a hostname will always return true (fails closed). For names, use Policy.ResolvePublicAddrs or Policy.CheckURL, and pin the returned addresses.
func (Policy) CheckRedirect ¶
func (p Policy) CheckRedirect(next func(req *http.Request, via []*http.Request) error) func(req *http.Request, via []*http.Request) error
CheckRedirect returns an http.Client CheckRedirect function that enforces redirect policy (pass nil for next, or a caller callback to run alongside).
The caller callback runs first. Any non-nil result it returns — including http.ErrUseLastResponse — stops the client from following the redirect, so no unvalidated request is ever sent; that result is returned unchanged. Only when the caller approves (returns nil, having possibly mutated the request) does the policy validate, and it validates the request that will actually go on the wire:
- Hop cap: at most Policy.MaxRedirects redirects (default 10; 0 follows none).
- Scheme: only http and https, and never an https→http downgrade — the dial layer cannot see schemes, so this is the one redirect property that must be enforced here.
- Host: literal and numeric Location hosts are classified synchronously and refused when blocked or malformed. Named hosts pass — their addresses are judged at dial time by Policy.ControlContext, which re-runs for every hop.
The policy is not skippable: a caller who returns nil cannot cause a fetch to a refused target, because validation runs after their callback on the final request.
func (Policy) CheckURL ¶
CheckURL returns nil if and only if rawURL's host resolves and every answer is admitted under the policy. A mixed public+blocked answer is refused: an unpinned connect could pick the blocked one. Errors are Violation (blocked or malformed; matches ErrBlocked) or UnresolvableError (matches ErrUnresolvable).
CheckURL judges addresses only. Scheme, port, and redirect policy are enforced by Policy.Client; DNS rebinding between this check and a later connect is only defeated by dial-time enforcement or caller pinning.
func (Policy) Client ¶
Client returns a real *http.Client enforcing the policy: every request URL is checked for a well-formed host via Policy.RoundTripper, every connection is judged at dial time via Policy.ControlContext, every redirect hop is re-validated via Policy.CheckRedirect, and requests time out after 30 seconds (adjust on the returned client if needed — but keep some timeout: a policy-refused target should not be able to stall callers either).
func (Policy) Control ¶
Control is Policy.ControlContext without a context, for net.Dialer's Control field and APIs that take the two-argument form.
func (Policy) ControlContext ¶
ControlContext is the enforcement core: install it as net.Dialer's ControlContext and every socket connect attempt — each Happy Eyeballs race entrant, each redirect hop's dial, each connection-pool miss — is judged by its literal address at the moment of connection. There is no gap between the address checked and the address connected, which is what defeats DNS rebinding.
Only tcp4/tcp6 are admitted. IPv4-mapped addresses are unmapped before judgment: at dial time the kernel really connects to the embedded IPv4. The port must be in the policy's allowed set (default 80 and 443; loopback under Policy.AllowLoopback is exempt).
func (Policy) Deny ¶
Deny returns a policy that refuses the given prefixes ahead of every other rule, including Allow and AllowLoopback. Invalid prefixes panic.
func (Policy) DialContext ¶
DialContext dials with enforcement installed. Network may be "tcp", "tcp4", or "tcp6"; the resolved per-family attempts are each judged by Policy.ControlContext.
Address literals — including legacy inet_aton spellings like "2130706433" or "0x7f000001" — are canonicalized to their dotted/colon form before the dialer runs, so the numeric-host defense holds here too: the raw token is never handed to a resolver that might treat it as a name and follow a wildcard answer. Named hosts are left for the dialer to resolve, and every resulting address is judged by Policy.ControlContext.
Example ¶
Wiring the enforcement layer into an existing transport by hand.
package main
import (
"errors"
"fmt"
"net/http"
surfguard "github.com/basecamp/surfguard/go"
)
func main() {
policy := surfguard.Policy{}
transport := &http.Transport{
Proxy: nil, // never proxy: the proxy address would be judged instead of the target
DialContext: policy.DialContext,
}
client := &http.Client{Transport: transport, CheckRedirect: policy.CheckRedirect(nil)}
_, err := client.Get("http://127.0.0.1/")
fmt.Println(errors.Is(err, surfguard.ErrBlocked))
}
Output: true
func (Policy) IANASpecialUse ¶
IANASpecialUse returns a policy that additionally blocks every prefix in the checked-in IANA IPv4 and IPv6 special-purpose registry snapshots, including globally reachable service infrastructure (AMT, AS112, the whole NAT64 well-known prefix). The tables also apply to NAT64/SIIT-embedded IPv4, so they cannot be bypassed via IPv6 encoding.
Use it for advertised or discovered infrastructure values — data a remote peer chose. A target must never choose its own policy; trusted consumer code chooses it.
Example ¶
Advertised or discovered values get the stricter policy; the consumer chooses it, never the target.
package main
import (
"errors"
"fmt"
surfguard "github.com/basecamp/surfguard/go"
)
func main() {
policy := surfguard.Policy{}.IANASpecialUse()
client := policy.Client()
_, err := client.Get("http://[64:ff9b::5db8:d822]/") // NAT64 WKP, even wrapping a public IPv4
fmt.Println(errors.Is(err, surfguard.ErrBlocked))
}
Output: true
func (Policy) MaxRedirects ¶
MaxRedirects returns a policy whose client follows at most n redirects (default 10). Zero means redirects are not followed at all. Negative n panics.
func (Policy) ResolvePublicAddrs ¶
ResolvePublicAddrs resolves host and returns every admitted address, IPv4 before IPv6 with resolver order retained within each family — a deterministic failover order for callers that pin the selected address at connection time. Blocked answers are filtered, so a resolvable host whose every answer is blocked yields an empty slice and a nil error.
Names are looked up per address family (see Resolver). Address literals — including legacy inet_aton spellings — are classified authoritatively without DNS. Malformed hosts return a Violation with ReasonMalformedHost (where Ruby's resolve_public_ips returns [] silently; Go callers check errors). An empty or invalid DNS answer returns an UnresolvableError, which is deliberately not part of the ErrBlocked family: it means retry later, not deactivate.
Example ¶
Classify-then-pin: resolve once, keep the hostname for Host/SNI, and connect to the returned addresses yourself.
package main
import (
"context"
"errors"
"fmt"
surfguard "github.com/basecamp/surfguard/go"
)
func main() {
addrs, err := surfguard.ResolvePublicAddrs(context.Background(), "93.184.216.34")
if err != nil {
var unresolvable *surfguard.UnresolvableError
if errors.As(err, &unresolvable) {
// Retry later: the host may resolve next time.
}
return
}
for _, addr := range addrs {
fmt.Println(addr) // dial these, in order
}
}
Output: 93.184.216.34
func (Policy) RoundTripper ¶
func (p Policy) RoundTripper() http.RoundTripper
RoundTripper returns Policy.Transport wrapped in the URL-shape check, for callers assembling their own *http.Client. It validates the host of every request it carries — the initial one and each redirect hop — before delegating.
This check cannot live at the dial layer: an http.Transport dials req.URL.Hostname(), which has already stripped the brackets from an authority, so by then a bracketed host is indistinguishable from a bare one. Nor can it be left to net/url: its IP-literal validation tightened after Go 1.23, this module's floor, where url.Parse still accepts "http://[example.com]/" and reports the host as the ordinary name "example.com".
func (Policy) Transport ¶
Transport returns a real *http.Transport wired to the enforcement layer. Proxying is deliberately disabled — with a proxy configured, dial-time checks would judge the proxy's address rather than the target's, which is the classic bypass in copy-pasted Control snippets. Do not set Proxy on the returned transport.
It judges addresses, not URL shape: an http.Transport dials req.URL.Hostname(), so a malformed authority is already normalized away by the time it is reached. Wrap it in Policy.RoundTripper — as Policy.Client does — to refuse malformed hosts as well.
func (Policy) WithResolver ¶
WithResolver returns a policy whose resolution layer (Policy.CheckURL, Policy.ResolvePublicAddrs) uses r instead of net.DefaultResolver. It is L2-only by design: it does not, and cannot, reconfigure the net.Dialer the enforcement layer builds (that field is a concrete *net.Resolver, whereas this seam is an interface so tests can inject fakes). The dial layer is unaffected regardless — it judges the literal address of every connect attempt no matter who resolved it, so there is no pre-check/dial gap to exploit. A nil r restores net.DefaultResolver.
type Reason ¶
type Reason int
Reason identifies which policy gate refused the request.
const ( // ReasonBlockedAddr: the address is not publicly routable under the // active policy. ReasonBlockedAddr Reason = iota // ReasonMalformedHost: the host is not a well-formed hostname or // address literal. Malformed input always fails closed. ReasonMalformedHost // ReasonNetwork: the dial network was not tcp4/tcp6. ReasonNetwork // ReasonPort: the port is outside the policy's allowed set. ReasonPort // ReasonScheme: the URL scheme was not http or https. ReasonScheme // ReasonRedirectDowngrade: a redirect attempted an https→http downgrade. ReasonRedirectDowngrade // ReasonTooManyRedirects: the redirect hop cap was exceeded. ReasonTooManyRedirects )
type Resolver ¶
type Resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
Resolver is the DNS seam used by the resolution layer. *net.Resolver satisfies it; tests substitute deterministic fakes.
The resolution layer queries "ip4" and "ip6" separately and never "ip", so an implementation must honor network: a combined lookup loses whether an answer came from an A or a AAAA record, and that distinction is what separates an ordinary IPv4 address from a hostile IPv4-mapped AAAA.
The two queries are issued concurrently, so an implementation must be safe for concurrent use by multiple goroutines (*net.Resolver is).
Report a family that holds no records as a *net.DNSError with IsNotFound set, or as an empty answer with a nil error. Any other error is read as an indefinite failure — the family's addresses are unknown rather than absent — and makes the whole lookup unresolvable, because a host must not be judged on one family while the other went unexamined.
type UnresolvableError ¶
type UnresolvableError struct {
// Host is the host that could not be resolved.
Host string
// Err is the underlying resolver error; nil when the lookup merely
// returned no (valid) answers.
Err error
}
UnresolvableError reports a host whose lookup returned no usable addresses. Its Error text is fixed; the underlying resolver error is reachable only through the Err field or errors.Is/As, never through the message (resolver errors embed attacker-controlled detail).
func (*UnresolvableError) Error ¶
func (u *UnresolvableError) Error() string
func (*UnresolvableError) Unwrap ¶
func (u *UnresolvableError) Unwrap() []error
Unwrap exposes ErrUnresolvable and the underlying resolver cause.
A cause that itself matches ErrBlocked is deliberately left out of the chain: a Resolver is caller-supplied code, and one that applies its own policy could return that sentinel. Carrying it here would make a single error match both families at once and collapse the retry-versus-deactivate distinction they exist to draw — a caller that deactivates targets on ErrBlocked would deactivate one whose lookup merely failed. The cause stays reachable through the Err field either way.
type Violation ¶
type Violation struct {
// Host is the host string the refusal applies to, when known.
Host string
// Addr is the refused address, when the refusal was address-level.
Addr netip.Addr
// Port is the refused port, when the refusal was connection-level.
Port uint16
// Reason identifies the gate that refused.
Reason Reason
}
Violation is a policy refusal. Its Error text is a fixed message per Reason — never the host, address, or port, because refusal text tends to end up in user-visible responses and logs and must not disclose what was probed or what it resolved to. The structured fields are available via errors.As for callers that log deliberately.
The redaction guarantee covers this surfguard error only. When a refusal surfaces through http.Client, net/http and net wrap it: Client.Do returns a *url.Error whose Error() prints the request URL, and a dial-time refusal is wrapped in a *net.OpError whose Error() prints the remote address. Callers that must not leak the target should classify with errors.Is (against ErrBlocked) and log the extracted *Violation — not the outer error's text.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Command generate renders go/policy_iana.go from the IANA registry snapshots mirrored under go/testdata/iana — a checked-in copy of the repo-root script/iana snapshots the Ruby implementation also generates from (the two mirrors are kept identical by a drift test), so the module generates self-contained while the policy source of truth stays shared.
|
Command generate renders go/policy_iana.go from the IANA registry snapshots mirrored under go/testdata/iana — a checked-in copy of the repo-root script/iana snapshots the Ruby implementation also generates from (the two mirrors are kept identical by a drift test), so the module generates self-contained while the policy source of truth stays shared. |