safedial

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 25, 2026 License: MIT Imports: 13 Imported by: 0

README

safedial

Go Reference

SSRF-hardened dialers and HTTP clients for Go services that connect to destinations influenced by someone other than the service operator: URLs submitted by end users, org-admin-configured integrations, webhook subscriptions, OAuth/OIDC discovery documents, MCP servers, and similar.

Without a guard, any of those can point a server at 169.254.169.254, a loopback admin API, or an internal service, and the server will connect with its own network position and credentials.

What it does

  • Blocks private and special-use destinations by default. Loopback, RFC 1918, link-local (including cloud metadata), CGNAT, multicast, unspecified, documentation, benchmarking, and the IPv6 special-use ranges the standard library does not classify.
  • Prevents DNS rebinding. Hostnames are resolved once, every resolved address is validated, and the connection goes to a validated IP directly. A resolver that answers with any blocked address fails the whole dial rather than racing it. TLS verification still uses the hostname.
  • Checks again at connect time. Connections made by net.Dialer pass a second policy check at the socket seam — the address policy and, when configured, the port allowlist — independent of the resolution and pinning logic.
  • Decodes NAT64. Addresses under the RFC 6052 well-known prefix 64:ff9b::/96 (and operator-declared RFC 8215 prefixes) have their embedded IPv4 destination extracted and validated with the full IPv4 policy, so a translator is not a side door to blocked targets.
  • Normalizes address forms. IPv4-mapped IPv6 addresses are unmapped and IPv6 zones are stripped before policy checks, so ::ffff:10.0.0.1 cannot bypass a 10.0.0.0/8 block.
  • Controls redirects. Blocked IP literals are rejected before a request is attempted, every hop goes through the guarded dialer, and stricter policies (same-origin only, deny all) are available for credentialed flows such as OAuth token exchange.
  • Hardens the transport. Proxies, alternate dial paths, and TLS protocol upgrades inherited from the base transport (which share connection pools with the unguarded base) are cleared so the guarded dialer is the only way out; HTTP/2 stays enabled through the standard library's own implementation.
  • Supports explicit allowlists. Deployments that legitimately need to reach internal destinations opt in per CIDR; allowed prefixes take precedence over every block rule. NAT64 forms are decoded before the allowlist is consulted, so allowing a translator's IPv6 range cannot skip validation of the IPv4 destinations it embeds.
  • Supports caller-defined connection policy. Deployments can add blocked CIDRs and opt in to a port allowlist for every guarded connection, including redirect hops.
  • Splits dial deadlines, keeps Happy Eyeballs. The remaining deadline is divided across resolved addresses so one black-holed address cannot consume the whole budget, and dual-stack destinations keep net.Dialer's address-family fallback race so a broken family does not stall the dial.

Usage

import "github.com/coder/safedial"

// Inherit timeouts and transport settings from an existing client, allow a
// deployment-configured internal range, and keep OAuth redirects on-origin.
base := &http.Client{Timeout: 15 * time.Second}
allowed, err := safedial.ParseAllowedPrefix("10.2.0.0/16")
if err != nil {
    // Reject the configuration.
}
client := safedial.NewHTTPClient(base,
    safedial.WithAllowedPrefixes(allowed),
    safedial.WithRedirectPolicy(safedial.RedirectSameOrigin),
)

resp, err := client.Get(userProvidedURL)
if err != nil {
    // Map policy rejections to caller-facing validation errors:
    // 400, not 502. The destination was rejected, nothing was dialed.
    var blocked *safedial.BlockedError
    if errors.As(err, &blocked) {
        return fmt.Errorf("destination not allowed: %w", err)
    }
    return err
}
defer resp.Body.Close()

For non-HTTP protocols or hand-built transports, safedial.NewDialContext(nil) returns a guarded DialContext function that applies the same policy and accepts the same options.

What it does not do

  • It does not protect operator-controlled destinations. A deployment operator's own OIDC issuer, SMTP smarthost, or telemetry endpoint often legitimately lives on an internal address, and the operator already controls the process. Wrapping those breaks real deployments without crossing a trust boundary.
  • It does not filter by scheme or hostname. Validate URLs before making requests. Port filtering is available as opt-in dial-layer policy through WithAllowedPorts.
  • It does not sandbox the response. What the caller does with fetched bytes is out of scope.

Stability

The address policy (which ranges are blocked) may gain new special-use ranges in minor releases; treat additions as hardening, not breaking changes. The Go API follows semver.

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func CheckAddr

func CheckAddr(addr netip.Addr, opts ...Option) error

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

func CheckSameOriginRedirect(req *http.Request, via []*http.Request) error

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

func NewHTTPClient(base *http.Client, opts ...Option) *http.Client

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

func NewTransport(base *http.Transport, opts ...Option) *http.Transport

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

func ParseAllowedPrefix(raw string) (netip.Prefix, error)

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.

func ParseNAT64Prefix

func ParseNAT64Prefix(raw string) (netip.Prefix, error)

ParseNAT64Prefix parses an RFC 6052 NAT64 translation prefix for WithNAT64Prefixes. Every embedding layout defined by RFC 6052 is supported: IPv6 prefixes of length 32, 40, 48, 56, 64, or 96.

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

func WithAllowedPorts(ports ...uint16) Option

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

func WithAllowedPrefixes(prefixes ...netip.Prefix) Option

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

func WithBlockedPrefixes(prefixes ...netip.Prefix) Option

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

func WithNAT64Prefixes(prefixes ...netip.Prefix) Option

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

type PortBlockedError struct {
	Host string
	Port uint16
}

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
)

Jump to

Keyboard shortcuts

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