surfguard

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 11 Imported by: 0

README

Surfguard for Go

Surfguard resolves, classifies, and — unlike the Ruby gem — enforces network address policy for Go programs that fetch URLs supplied by someone else (SSRF). The Ruby implementation at the repository root is the policy source of truth; this module shares its IANA registry snapshots, its conformance corpus, and its guarantees, then adds the layers that are cheap and composable in Go: dial-time enforcement and a hardened *http.Client.

import surfguard "github.com/basecamp/surfguard/go"

Go 1.23 or newer. Zero dependencies (enforced in CI: no go.sum, no require).

Quick start

// A drop-in client: every socket connect attempt is judged by its literal
// address (DNS rebinding is caught per connection, per redirect hop, per
// Happy Eyeballs race), redirects are re-validated hop by hop, proxying is
// disabled.
client := surfguard.Client()

// Advertised or discovered infrastructure values get the stricter policy.
// A target must never choose its own policy; trusted consumer code does.
discovery := surfguard.Policy{}.IANASpecialUse().Client()

// Test fixtures: loopback admitted on any port, everything else still strict.
fixture := surfguard.Policy{}.AllowLoopback().Client()

if _, err := client.Get(userSuppliedURL); errors.Is(err, surfguard.ErrBlocked) {
    // policy refusal: deactivate the target
}
// The client surfaces DNS failures as the standard net errors (e.g.
// *net.DNSError), not ErrUnresolvable. ErrUnresolvable is an L2 signal: use
// CheckURL / ResolvePublicAddrs as a pre-flight when you need the explicit
// retry-vs-deactivate distinction before dialing.
if err := surfguard.CheckURL(ctx, userSuppliedURL); errors.Is(err, surfguard.ErrUnresolvable) {
    // lookup came back empty: retry later
}

Layers

The zero value of Policy is the full default policy; derivation methods return adjusted copies and accumulate.

Layer API Guarantee
Classification Blocked(netip.Addr), BlockedHost(string) pure verdicts; invalid, zoned, and malformed input fails closed; never DNS
Resolution ResolvePublicAddrs(ctx, host), CheckURL(ctx, url) every answer judged; each address family looked up separately; legacy numeric spellings classified without DNS; malformed numeric tokens refused outright
Enforcement Control, ControlContext, DialContext the literal address of every connect attempt is judged at the moment of connection — no check-to-use gap; DialContext canonicalizes legacy-numeric literals before the resolver can see them
Client Transport(), RoundTripper(), Client(), CheckRedirect(next) real *http.Transport/*http.Client; Proxy: nil; malformed hosts and non-http(s) schemes refused before the transport, on the initial request and every hop; per-hop downgrade, host, and (via dial) address+port re-validation

DialContext gives the numeric-host defense at dial time too: a legacy spelling like 2130706433 or 0x7f000001 is canonicalized to its address (127.0.0.1) and judged, never handed to a resolver that a wildcard answer could hijack — so Client().Get("http://2130706433/") is refused exactly as CheckURL would refuse it.

Names are resolved per address family — "ip4" and "ip6", never "ip". A combined lookup loses whether an answer came from an A or a AAAA record, and the pure-Go resolver spells an A record as its IPv4-mapped form (::ffff:127.0.0.1 where cgo gives 127.0.0.1); since classification refuses every mapped address as a hostile AAAA, a combined lookup would drop ordinary IPv4 answers on that backend. An ip4 answer is therefore unmapped and judged as the IPv4 it names, while an ip6 answer is judged as it stands, so a mapped value there is still refused. The two queries are issued concurrently, as a combined lookup does internally, so splitting them costs no extra round trip.

A verdict is only formed from a complete picture of the host. A family may be missing, but only definitively — no error, or a lookup reporting that the records do not exist. A timeout, SERVFAIL, or a context that ends mid-resolution leaves it unknown whether that family held an address worth refusing, so the host is reported unresolvable rather than judged on the other family alone.

A WithResolver implementation must therefore honor the network argument, be safe for concurrent use, and report an absent family as a *net.DNSError with IsNotFound (or an empty answer with a nil error) so it is distinguishable from a failure.

Client() checks the shape of every request URL — the initial one and each redirect hop — before the transport sees it, because that is the only layer where the evidence still exists: an *http.Transport dials req.URL.Hostname(), which has already stripped the brackets from an authority. Nor can this be left to net/url, whose IP-literal validation tightened after Go 1.23, the module floor: there url.Parse accepts http://[example.com]/ and reports the host as the ordinary name example.com. Transport() still returns a real *http.Transport for callers who want to configure one, but it judges addresses rather than URL shape — assemble a custom client from RoundTripper() to keep both.

CheckRedirect(next) runs the caller's next callback first; any non-nil result it returns — including http.ErrUseLastResponse — stops the follow and is returned unchanged, and only an approved (nil) redirect is validated, so the policy always judges the request that actually goes on the wire and is never skippable.

Bare classification and resolution do not bind a later connection: callers using them must pin the returned addresses at connection time (keep the hostname for Host/SNI). The enforcement layer is what closes DNS rebinding.

Policies

The default policy blocks the documented IPv4 SSRF deny ranges — private, loopback, link-local, CGNAT, 0/8, TEST-NETs, benchmarking, 6to4 relay, multicast, reserved, broadcast, and the Azure WireServer address 168.63.129.16, which sits inside public space and is missing from every registry-derived list. IPv6 is admitted only when inside a checked-in IANA Status=ALLOCATED global unicast prefix (unallocated space is denied by construction — deliberately not the far broader 2000::/3), minus explicit denies. IPv4-mapped and IPv4-compatible forms and the NAT64 local-use prefix are refused outright; NAT64 WKP and SIIT forms decode their embedded IPv4 and re-check it.

IANASpecialUse() additionally blocks every prefix in the checked-in IANA special-purpose registries — AMT, AS112, the whole NAT64 well-known prefix — applied to transition-embedded IPv4 as well, so IPv6 encoding is not a bypass.

Derivations: Allow/Deny (netip prefixes; Deny > structural defenses > special-use tables > Allow > default tables), AllowLoopback (fixtures), AllowPorts/AllowAllPorts (dial layer; default {80, 443}), MaxRedirects (default 10), WithResolver (resolution seam).

Refused vs unresolvable

Condition Error errors.Is
blocked address, malformed host, refused port/network/scheme/redirect *Violation ErrBlocked
empty, oversized, or invalid resolver answer; resolver failure *UnresolvableError ErrUnresolvable

The two families are deliberately unrelated: blocked means deactivate the target, unresolvable means retry later. Both survive the url.Error / net.OpError wrapping of net/http, so errors.Is(err, surfguard.ErrBlocked) works on the error a real client.Do returns.

The fixed, non-leaking message is a property of the surfguard error itself: (*Violation).Error() and (*UnresolvableError).Error() never contain the host, address, or port. The wrappers net/http adds do leak — a *url.Error prints the request URL and a dial-time *net.OpError prints the remote address. Code that must not disclose the target should classify with errors.Is/errors.As and log the extracted *Violation, not the outer error's text.

Ruby parity and divergences

The shared corpus under ../conformance asserts identical classification verdicts in both implementations. Deliberate divergences:

Ruby Go
Malformed host in resolution silent [] *Violation (ReasonMalformedHost) — Go callers check errors
Legacy numeric parsing platform getaddrinfo(AI_NUMERICHOST) own inet_aton grammar: identical on every platform, leading zeros always octal
BlockedHost on a legacy spelling ("2130706433") blocked_address? fails closed (not an IPAddr) classified authoritatively, consistent with resolution
Mapped IPv4 (::ffff:a.b.c.d) blocked in classification blocked in classification; the dial layer unmaps and judges the embedded IPv4, because that is what the kernel connects to
Enforcement / HTTP layers out of scope by design (caller pins) Control/DialContext/Client provided
Adversarial-object hardening (bind_call) required moot under Go's type system

Not provided

No proxy support: Transport() sets Proxy: nil because a proxied request would have the proxy's address judged instead of the target's. No IDN conversion: punycode-encode before calling — a non-ASCII host is refused as malformed rather than folded, because http.Transport IDNA-normalizes one before dialing (ⓛocalhost becomes localhost) and the host judged would not be the host dialed. No response-size limits or request deadlines beyond the client's 30s timeout: those remain caller policy.

Registry data and releases

The module is self-contained: it reads its shared data from testdata/conformance and testdata/iana, checked-in mirrors of the repo-root conformance/ corpus and script/iana/ snapshots (the same snapshots the Ruby constants are generated from). A drift test asserts the mirrors are byte-identical to those sources when the full repo is present, and the policy tables are generated (go generate) from testdata/iana with a CI staleness check — so nothing falls out of sync, and go test ./... passes against a downloaded module zip that contains only files beneath go/.

Module tags follow the Go subdirectory convention: go/vX.Y.Z. New denies are a minor bump with a prominent changelog entry; policy changes land in conformance/ and both implementations in one commit.

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

Examples

Constants

This section is empty.

Variables

View Source
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.

View Source
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 Blocked

func Blocked(addr netip.Addr) bool

Blocked reports whether addr must be refused under the default policy.

func BlockedHost

func BlockedHost(host string) bool

BlockedHost judges a host string under the default policy without DNS.

func CheckURL

func CheckURL(ctx context.Context, rawURL string) error

CheckURL checks rawURL under the default policy.

func Client

func Client() *http.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

func ResolvePublicAddrs

func ResolvePublicAddrs(ctx context.Context, host string) ([]netip.Addr, error)

ResolvePublicAddrs resolves host under the default policy.

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

func (p Policy) Allow(prefixes ...netip.Prefix) Policy

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

func (p Policy) AllowAllPorts() Policy

AllowAllPorts returns a policy whose connection layer does not restrict ports. Address policy still applies.

func (Policy) AllowLoopback

func (p Policy) AllowLoopback() Policy

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

func (p Policy) AllowPorts(ports ...uint16) Policy

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

func (p Policy) Blocked(addr netip.Addr) bool

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

func (p Policy) BlockedHost(host string) bool

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

func (p Policy) CheckURL(ctx context.Context, rawURL string) error

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

func (p Policy) Client() *http.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

func (p Policy) Control(network, address string, _ syscall.RawConn) error

Control is Policy.ControlContext without a context, for net.Dialer's Control field and APIs that take the two-argument form.

func (Policy) ControlContext

func (p Policy) ControlContext(_ context.Context, network, address string, _ syscall.RawConn) error

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

func (p Policy) Deny(prefixes ...netip.Prefix) Policy

Deny returns a policy that refuses the given prefixes ahead of every other rule, including Allow and AllowLoopback. Invalid prefixes panic.

func (Policy) DialContext

func (p Policy) DialContext(ctx context.Context, network, address string) (net.Conn, error)

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

func (p Policy) IANASpecialUse() Policy

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

func (p Policy) MaxRedirects(n int) Policy

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

func (p Policy) ResolvePublicAddrs(ctx context.Context, host string) ([]netip.Addr, error)

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

func (p Policy) Transport() *http.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

func (p Policy) WithResolver(r Resolver) Policy

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
)

func (Reason) String

func (r Reason) String() string

String names the reason for structured logging. It never echoes input.

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.

func (*Violation) Error

func (v *Violation) Error() string

func (*Violation) Unwrap

func (v *Violation) Unwrap() error

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL