Documentation
¶
Overview ¶
Package safedial hardens outbound requests against server-side request forgery (SSRF) when the destination host is influenced by someone other than the operator of the calling service: end users, org admins, webhook subscriptions, OAuth discovery documents, and similar.
The guard validates every destination IP address after DNS resolution and dials the validated address directly, so a hostile resolver cannot swap in a private address between validation and connection (DNS rebinding). Private, loopback, link-local, multicast, and special-use ranges are blocked by default; deployments reach intentionally internal destinations through an explicit allowlist.
This package is not meant to wrap destinations that only the deployment operator controls (their own OIDC issuer, SMTP smarthost, telemetry endpoint). Operators legitimately point those at internal addresses, and they already control the process.
Index ¶
- func CheckAddr(addr netip.Addr, opts ...Option) error
- func CheckSameOriginRedirect(req *http.Request, via []*http.Request) error
- func NewDialContext(base ContextDialer, opts ...Option) func(ctx context.Context, network, addr string) (net.Conn, error)
- func NewHTTPClient(base *http.Client, opts ...Option) *http.Client
- func NewTransport(base *http.Transport, opts ...Option) *http.Transport
- func ParseAllowedPrefix(raw string) (netip.Prefix, error)
- func ParseNAT64Prefix(raw string) (netip.Prefix, error)
- type BlockedError
- type ContextDialer
- type Option
- type PortBlockedError
- type RedirectPolicy
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func CheckAddr ¶
CheckAddr validates a single IP address against the destination policy. It returns a *BlockedError when the address is blocked, an error when the address is invalid (such as the zero netip.Addr), and nil otherwise. Use it for pre-checks on caller-supplied IP literals; hostname-based destinations must go through the guarded dialer instead so the post-resolution addresses are what get validated.
func CheckSameOriginRedirect ¶
CheckSameOriginRedirect allows only method-preserving redirects within the original scheme, host, and port. It can be used directly as an http.Client CheckRedirect.
It restricts redirect targets only; it performs no destination address validation of its own. For SSRF protection the client's transport must still dial through the guard, as clients built by NewHTTPClient do.
func NewDialContext ¶
func NewDialContext(base ContextDialer, opts ...Option) func(ctx context.Context, network, addr string) (net.Conn, error)
NewDialContext returns a DialContext function that validates every destination against the policy before connecting through base. A nil base uses a dialer with http.DefaultTransport's timeout and keep-alive. A base *net.Dialer's Timeout and Deadline bound each whole dial operation, resolution and all connect attempts combined, unless the caller's deadline is sooner, and its Resolver, when set, resolves hostnames for validation. Any other ContextDialer implementation is bounded only by the caller's context, so pass a context with a deadline. Only tcp, tcp4, and tcp6 networks are permitted.
Hostnames are resolved first and each resolved address is validated; the connection is then made to a validated IP directly, so a hostile resolver cannot rebind the name between validation and dialing. IP literals keep their IPv6 zone when dialed. Connections made by a *net.Dialer, including the nil-base default, are checked again at the socket seam: the address policy and, when configured, the port allowlist. Custom dialers rely on the resolve-and-pin validation alone because they cannot carry a net.Dialer Control hook.
When a hostname resolves to both address families, the validated addresses are dialed with net.Dialer's Happy Eyeballs behavior: the second family starts after a short fallback delay, so base may see concurrent DialContext calls for one dial. A base *net.Dialer's FallbackDelay is honored, including a negative value to disable the race; any other ContextDialer implementation, including wrappers around a *net.Dialer, gets the standard 300ms delay.
Use this for non-HTTP protocols or hand-built transports. For HTTP, prefer NewHTTPClient or NewTransport.
func NewHTTPClient ¶
NewHTTPClient returns an *http.Client that blocks private and special-use destinations unless explicitly allowed. It validates and dials resolved IPs directly to prevent DNS rebinding and applies the configured redirect policy. Base client timeouts, cookie jar, CheckRedirect, and non-routing transport settings are preserved; a nil base gets a 30 second timeout. A base CheckRedirect runs before the guard's redirect policy: it can still stop a redirect (including with http.ErrUseLastResponse), and redirects it allows remain subject to the guard.
The guard must own the dial path, so only a nil or *http.Transport base transport can be preserved. Any other RoundTripper (tracing wrappers, test doubles) cannot be guarded and NewHTTPClient panics rather than silently replacing it; unwrap to the underlying *http.Transport first. The same applies to a globally replaced http.DefaultTransport when base or its transport is nil.
func NewTransport ¶
NewTransport returns a clone of base (or of http.DefaultTransport when base is nil) whose every connection goes through the guarded dialer. Proxies, alternate dial paths, and TLS protocol upgrades inherited from the base (whose connection pools are shared with the unguarded base and so could serve unvalidated connections) are cleared so the guard cannot be bypassed; all other transport settings are preserved. A base transport's own dial functions, including any custom timeouts, local address bindings, or socket controls they carried, are replaced by the guarded dialer and its defaults. A base that enabled HTTP/2 through http2.ConfigureTransport keeps HTTP/2 support via the stdlib's own implementation with a private connection pool.
When base is nil and http.DefaultTransport has been globally replaced with something other than *http.Transport, it cannot be guarded and NewTransport panics rather than silently substituting a blank transport.
func ParseAllowedPrefix ¶
ParseAllowedPrefix parses an allowed CIDR and converts IPv4-mapped IPv6 prefixes to equivalent IPv4 prefixes. Prefixes shorter than the 96-bit IPv4-mapped marker cannot be represented as IPv4 ranges.
Types ¶
type BlockedError ¶
type BlockedError struct {
// Host is the host portion of the requested address: a hostname when
// the destination was resolved, or the literal IP that was dialed.
Host string
// Addr is the IP address the block verdict applied to, with any IPv6
// zone stripped and IPv4-mapped form unmapped. For NAT64 translation
// forms this is the embedded IPv4 destination that was blocked, not
// the outer IPv6 address, unless a WithBlockedPrefixes rule matched
// the outer translation form itself, in which case it is that outer
// address.
Addr netip.Addr
}
BlockedError reports a destination that was rejected by the address policy, as opposed to a network failure. Use errors.As to map policy rejections to caller-facing validation errors.
func (*BlockedError) Error ¶
func (e *BlockedError) Error() string
type ContextDialer ¶
type ContextDialer interface {
DialContext(ctx context.Context, network, addr string) (net.Conn, error)
}
ContextDialer is the subset of net.Dialer used to open connections after destination validation.
type Option ¶
type Option func(*config)
Option configures the destination policy.
func WithAllowedPorts ¶ added in v0.2.0
WithAllowedPorts restricts connections to the given ports. An empty list leaves ports unrestricted. This is dial-layer policy and applies to every connection, including redirect hops; validating schemes and hostnames remains the caller's responsibility. Connections made by a *net.Dialer re-check the port at connect time, the same backstop applied to the address policy.
func WithAllowedPrefixes ¶
WithAllowedPrefixes exempts destinations inside the given CIDRs from blocking. Allowed prefixes take precedence over every block rule, so scope them as narrowly as possible. Parse operator-supplied values with ParseAllowedPrefix so IPv4-mapped IPv6 forms cannot bypass the policy.
NAT64 translation forms (the RFC 6052 well-known prefix and any WithNAT64Prefixes ranges) are decoded before the allowlist is consulted, so allowing a translator's IPv6 range does not skip validation of the IPv4 destinations it embeds. To reach translated destinations, allow the embedded IPv4 range instead.
IPv4-mapped IPv6 prefixes are converted to their IPv4 equivalents, same as ParseAllowedPrefix, because addresses are unmapped before matching; a mapped prefix shorter than 96 bits cannot be represented as an IPv4 range and panics.
func WithBlockedPrefixes ¶ added in v0.2.0
WithBlockedPrefixes blocks destinations inside the given CIDRs. Allowed prefixes take precedence over caller-supplied blocks, so narrow the allowed prefixes instead when part of an allowed range must remain blocked. Parse operator-supplied values with ParseAllowedPrefix so IPv4-mapped IPv6 forms cannot bypass the policy.
A prefix covering NAT64 translation forms matches the outer IPv6 address before its embedded IPv4 destination is decoded, so blocking a translator's range blocks the translator itself regardless of what it embeds; that outer match is the one place a caller block precedes the allowlist, which only ever matches decoded destinations.
IPv4-mapped IPv6 prefixes are converted to their IPv4 equivalents, same as WithAllowedPrefixes; a mapped prefix shorter than 96 bits cannot be represented as an IPv4 range and panics. An invalid prefix (the zero netip.Prefix or one built from bad PrefixFrom arguments) contains no addresses, which would turn the deny rule into a silent no-op, so it panics as well.
func WithNAT64Prefixes ¶
WithNAT64Prefixes declares deployment-specific NAT64 translation prefixes (RFC 8215 network-specific prefixes). Addresses inside a declared prefix have their embedded IPv4 destination extracted and validated with the full IPv4 policy, exactly like the RFC 6052 well-known prefix, which is always handled. Each prefix must be an IPv6 prefix of an RFC 6052 length (32, 40, 48, 56, 64, or 96), as produced by ParseNAT64Prefix; other values panic. When declared prefixes overlap, the first prefix that contains an address (in the order given) decides the embedding layout.
func WithRedirectPolicy ¶
func WithRedirectPolicy(policy RedirectPolicy) Option
WithRedirectPolicy sets how clients built by NewHTTPClient treat HTTP redirects. The default is RedirectGuarded. The policy has no effect on NewDialContext or NewTransport, which never see redirects.
type PortBlockedError ¶ added in v0.2.0
PortBlockedError reports a destination rejected by the port policy. Use errors.As, as with BlockedError, to map policy rejections to caller-facing validation errors.
func (*PortBlockedError) Error ¶ added in v0.2.0
func (e *PortBlockedError) Error() string
type RedirectPolicy ¶
type RedirectPolicy int
RedirectPolicy selects how clients built by NewHTTPClient treat HTTP redirects.
const ( // RedirectGuarded follows up to 10 redirects to any http or https // destination. Every hop is still validated by the guarded dialer, // and redirects to blocked IP literals are rejected before a request // is attempted. RedirectGuarded RedirectPolicy = iota // RedirectSameOrigin allows only method-preserving redirects within // the original scheme, host, and port. Use it when a redirect must // not leak credentials or request bodies to another host, such as // OAuth token or revocation endpoints. RedirectSameOrigin // RedirectDeny rejects every redirect. RedirectDeny )