ssrfguard

package module
v0.2.1 Latest Latest
Warning

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

Go to latest
Published: Jun 28, 2026 License: MIT Imports: 9 Imported by: 0

README

ssrfguard

Go Reference Go

A small, dependency-free Go library that helps prevent Server-Side Request Forgery (SSRF). It validates outbound URLs and blocks requests to internal address ranges — and it can enforce the block at dial time, which is what makes it robust against DNS rebinding.

client := ssrfguard.New().Client()
resp, err := client.Get(userSuppliedURL) // refused if it dials an internal IP

Why dial-time matters

Most SSRF checks validate the URL up front: resolve the host, see a public IP, allow it. But an attacker who controls DNS can return a public IP at validation time and an internal IP a moment later at connect time (DNS rebinding) — bypassing the check entirely.

ssrfguard plugs into net.Dialer.Control, which runs after DNS resolution and just before the socket connects, so it inspects the address the connection will actually reach. The guarded http.Client is safe even when the hostname's resolution changes between validation and connection.

It blocks (unless you opt out):

  • Loopback127.0.0.0/8, ::1
  • Private — RFC 1918 (10/8, 172.16/12, 192.168/16) and RFC 4193 ULA (fc00::/7)
  • Link-local169.254.0.0/16 (including the cloud metadata endpoint 169.254.169.254) and fe80::/10
  • Unspecified0.0.0.0, ::

IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) is classified by its embedded IPv4 address, and only http/https schemes are allowed by default.

Install

go get github.com/richardwooding/ssrfguard

Requires Go 1.26+. No third-party dependencies.

Usage

client := ssrfguard.New().Client()
resp, err := client.Get(untrustedURL)
if errors.Is(err, ssrfguard.ErrBlockedAddress) {
    // refused to connect to an internal address
}

Compose over your own tuned transport — its settings are preserved, only the dialer is wrapped:

base := &http.Transport{MaxConnsPerHost: 10 /* TLS, proxy, pooling, … */}
client := &http.Client{Transport: ssrfguard.New().Transport(base)}

Or attach the guard to a dialer you already build:

dialer := &net.Dialer{Control: ssrfguard.New().Control}
Up-front validation

When you accept a URL into config or a database (and the request happens later), validate it eagerly too:

g := ssrfguard.New()
if err := g.ValidateURL(userSuppliedURL); err != nil {
    return fmt.Errorf("rejected: %w", err)
}

ValidateURL checks the scheme and host, and — for a literal IP or a resolvable name — that it isn't an internal address. A name that can't currently be resolved is allowed through; the dial-time guard remains the backstop.

ValidateURL resolves names with a background context, so the lookup is bounded only by the resolver. To impose a deadline — important when validating attacker-supplied URLs, since a slow DNS server would otherwise stall the call — use ValidateURLContext:

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := g.ValidateURLContext(ctx, userSuppliedURL); err != nil {
    return fmt.Errorf("rejected: %w", err)
}
Options
// Only allow https.
ssrfguard.New(ssrfguard.WithSchemes("https"))

// Permit internal ranges (trusted/dev environments).
ssrfguard.New(ssrfguard.WithAllowPrivate(true))

// Resolve names with a custom resolver — point DNS at a specific server, bound
// lookups with a Dial hook, or make tests hermetic. Defaults to net.DefaultResolver.
ssrfguard.New(ssrfguard.WithResolver(myResolver))

Comparison

  • doyensec/safeurl — a fuller-featured guarded client (allow/deny lists). ssrfguard is smaller and composes as a plain dialer/transport primitive with zero dependencies.
  • Hand-rolled "resolve then check the IP" snippets are common but validate at parse time only, leaving the DNS-rebinding window open. ssrfguard closes it.

Security note

SSRF defense is layered. ssrfguard covers URL scheme/host validation and internal-range blocking at dial time, but it does not (yet) restrict redirects, add allowlist-only modes, or inspect request bodies. Pair it with redirect policies (http.Client.CheckRedirect) and least-privilege networking as needed. Reports and contributions welcome.

Changelog

See CHANGELOG.md.

License

MIT

Documentation

Overview

Package ssrfguard helps prevent Server-Side Request Forgery (SSRF) in Go HTTP clients. It validates outbound URLs (scheme and host) and blocks requests to internal address ranges — loopback, private (RFC 1918 / RFC 4193), link-local (which includes the cloud metadata endpoint 169.254.169.254), and the unspecified address.

Crucially, the protection can run at dial time via Guard.Control, a net.Dialer Control hook that inspects the actual IP the connection is about to reach. This defeats DNS-rebinding attacks, where a hostname resolves to a harmless address during validation but to an internal one at connect time — something a parse-time-only check cannot catch.

Typical use, as a guarded HTTP client:

client := ssrfguard.New().Client()
resp, err := client.Get(userSuppliedURL) // blocked if it dials an internal IP

Or validate up front (for example, when accepting a URL into a config):

if err := ssrfguard.New().ValidateURL(userSuppliedURL); err != nil {
	// reject
}

For trusted environments, WithAllowPrivate disables internal-range blocking, and WithSchemes customizes the permitted URL schemes.

Example

Example shows a guarded HTTP client refusing to connect to an internal address. The test server listens on loopback, so the dial is blocked.

package main

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/richardwooding/ssrfguard"
)

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	}))
	defer srv.Close()

	_, err := ssrfguard.New().Client().Get(srv.URL)
	fmt.Println(errors.Is(err, ssrfguard.ErrBlockedAddress))
}
Output:
true

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrEmptyURL is returned by ValidateURL for an empty input.
	ErrEmptyURL = errors.New("ssrfguard: empty URL")
	// ErrInvalidURL is returned when a URL cannot be parsed.
	ErrInvalidURL = errors.New("ssrfguard: invalid URL")
	// ErrUnsupportedScheme is returned for a URL scheme not in the allowed set.
	ErrUnsupportedScheme = errors.New("ssrfguard: unsupported URL scheme")
	// ErrMissingHost is returned for a URL with no host.
	ErrMissingHost = errors.New("ssrfguard: URL has no host")
	// ErrBlockedAddress is returned when a destination resolves to, or is, a
	// blocked address (loopback, private, link-local, or unspecified).
	ErrBlockedAddress = errors.New("ssrfguard: destination address is blocked")
)

Errors returned by a Guard. Use errors.Is to test for them; ValidateURL and Control wrap them with additional context.

Functions

This section is empty.

Types

type Guard

type Guard struct {
	// contains filtered or unexported fields
}

Guard enforces an SSRF policy: which URL schemes are allowed and which destination IP addresses are blocked. Build one with New; the zero value is not usable. A Guard is immutable after construction and safe for concurrent use by multiple goroutines.

func New

func New(opts ...Option) *Guard

New returns a Guard with the given options. By default it allows the http and https schemes, blocks internal address ranges, and resolves named hosts with net.DefaultResolver.

func (*Guard) Client

func (g *Guard) Client() *http.Client

Client returns an *http.Client whose transport enforces the guard at dial time.

func (*Guard) Control

func (g *Guard) Control(_, address string, _ syscall.RawConn) error

Control is a net.Dialer Control hook that rejects connections whose destination address is blocked by the policy. Because it runs after DNS resolution and just before connecting, it inspects the address actually being dialed and so blocks DNS-rebinding attacks. Plug it into a dialer:

dialer := &net.Dialer{Control: guard.Control}

func (*Guard) Dialer

func (g *Guard) Dialer() *net.Dialer

Dialer returns a *net.Dialer whose Control hook enforces the guard, with the same default Timeout and KeepAlive as http.DefaultTransport's dialer.

func (*Guard) IsBlockedIP

func (g *Guard) IsBlockedIP(ip net.IP) bool

IsBlockedIP reports whether ip is blocked by the policy. When AllowPrivate is set, nothing is blocked. Otherwise loopback, private (RFC 1918 / RFC 4193), link-local unicast and multicast (including the 169.254.169.254 cloud metadata endpoint), and the unspecified address are blocked. IPv4-mapped IPv6 addresses are classified by their embedded IPv4 address.

func (*Guard) Transport

func (g *Guard) Transport(base *http.Transport) *http.Transport

Transport returns a clone of base whose DialContext enforces the guard at dial time. If base is nil, a clone of http.DefaultTransport is used.

func (*Guard) ValidateURL

func (g *Guard) ValidateURL(rawURL string) error

ValidateURL parses rawURL and checks it against the policy: it must be a parseable URL with an allowed scheme and a non-empty host. Unless AllowPrivate is set, a literal-IP host is checked directly, and a named host is resolved and rejected if any resolved address is blocked.

A named host that cannot currently be resolved is allowed, so transient DNS failures don't reject otherwise-valid URLs; the dial-time Guard.Control hook still blocks it if it later resolves to an internal address.

ValidateURL resolves named hosts with a background context, so the lookup is bounded only by the resolver's own settings. Use Guard.ValidateURLContext to impose a deadline or to make the lookup cancellable.

Example

ExampleGuard_ValidateURL shows up-front URL validation: the cloud metadata endpoint is blocked while a public address is allowed.

package main

import (
	"fmt"

	"github.com/richardwooding/ssrfguard"
)

func main() {
	g := ssrfguard.New()
	fmt.Println(g.ValidateURL("http://169.254.169.254/latest/meta-data/") != nil)
	fmt.Println(g.ValidateURL("https://8.8.8.8") == nil)
}
Output:
true
true

func (*Guard) ValidateURLContext added in v0.2.0

func (g *Guard) ValidateURLContext(ctx context.Context, rawURL string) error

ValidateURLContext is Guard.ValidateURL with a caller-supplied context that governs DNS resolution of named hosts. Pass a context with a deadline to bound the lookup, which a parse-time-only check otherwise leaves at the mercy of the resolver — a slow or unreachable DNS server can stall an unbounded lookup.

If ctx is canceled or its deadline elapses during resolution, it returns ctx.Err(). A genuine DNS resolution failure (an unresolvable name) is not an error: the host is allowed, leaving the dial-time Guard.Control hook to block it if it later resolves to an internal address.

type Option

type Option func(*Guard)

Option configures a Guard passed to New.

func WithAllowPrivate

func WithAllowPrivate(allow bool) Option

WithAllowPrivate controls whether destinations on internal ranges (loopback, private, link-local, unspecified) are permitted. The default is false, which blocks them. Set it to true only for trusted environments — for example, talking to services on a private network or localhost during development.

func WithResolver added in v0.2.0

func WithResolver(r *net.Resolver) Option

WithResolver sets the net.Resolver used to resolve named hosts during URL validation. The default is net.DefaultResolver. A nil resolver is ignored, leaving the default in place.

Supplying a custom resolver lets callers point DNS at a specific server, apply a net.Resolver.Dial hook (for example to enforce a timeout or to make tests hermetic by failing fast instead of touching the network), or otherwise control resolution. It composes with Guard.ValidateURLContext, which carries a deadline into the lookup.

func WithSchemes

func WithSchemes(schemes ...string) Option

WithSchemes sets the allowed URL schemes (compared case-insensitively). The default is "http" and "https". Passing no schemes leaves the default in place.

Jump to

Keyboard shortcuts

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