safehttp

package
v0.0.3 Latest Latest
Warning

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

Go to latest
Published: May 14, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package safehttp is the security-critical core that gates every outbound connection made by a scan. It validates user input, resolves DNS once, pins the destination IP for the lifetime of the scan, refuses off-host redirects, caps response bodies and rate-limits both clients and targets. See

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidScheme — the input scheme is not in the allow-list.
	ErrInvalidScheme = errors.New("safehttp: invalid scheme")
	// ErrInvalidHost — the hostname could not be parsed, is empty, or is
	// not a valid FQDN.
	ErrInvalidHost = errors.New("safehttp: invalid host")
	// ErrIPLiteral — the user supplied a bare IP; only hostnames are accepted.
	ErrIPLiteral = errors.New("safehttp: ip literal not accepted")
	// ErrUserInfo — the URL contains a userinfo component.
	ErrUserInfo = errors.New("safehttp: userinfo not accepted")
	// ErrCustomPortBlocked — a non-default port was requested while
	// allow_custom_ports is false.
	ErrCustomPortBlocked = errors.New("safehttp: custom port blocked")
	// ErrPrivateTargetBlocked — every resolved IP is in a blocked range.
	ErrPrivateTargetBlocked = errors.New("safehttp: private or reserved target blocked")
	// ErrNoAllowedIP — DNS returned no addresses for the host.
	ErrNoAllowedIP = errors.New("safehttp: no resolvable address for target")
	// ErrIPPinViolation — a Dial was attempted against an address other
	// than the pinned target IP.
	ErrIPPinViolation = errors.New("safehttp: ip-pin violation")
	// ErrOffHostRedirect — a redirect pointed away from the original host.
	ErrOffHostRedirect = errors.New("safehttp: off-host redirect refused")
	// ErrTooManyRedirects — redirect chain exceeded the configured limit.
	ErrTooManyRedirects = errors.New("safehttp: too many redirects")
	// ErrBodyTooLarge — the response body exceeded the configured cap.
	ErrBodyTooLarge = errors.New("safehttp: response body exceeds cap")
)

Sentinel errors. All are matchable via errors.Is.

Functions

func AllowRedirect

func AllowRedirect(target *Target, maxHops int) func(*http.Request, []*http.Request) error

AllowRedirect is the http.Client CheckRedirect predicate enforced by NewClient. It refuses any redirect that leaves target.Host (off-host) and caps the redirect chain at maxHops. If maxHops is zero or negative, no redirect is followed at all.

func DefaultLookup

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

DefaultLookup uses the Go default resolver.

func NewClient

func NewClient(opts ClientOpts) *http.Client

NewClient builds an *http.Client wired to the safehttp guarantees:

  • the underlying TCP dial always targets the pinned IP:port;
  • SNI and the Host header use the original FQDN;
  • redirects are refused when they leave the original host;
  • response bodies are wrapped in a length-capped reader.

func PinnedDialer

func PinnedDialer(target *Target, timeout time.Duration) *net.Dialer

PinnedDialer returns a *net.Dialer whose Control callback refuses to connect to any address other than target.AddrPort(). It is the second layer of the SSRF defence: callers should pass the pinned address to DialContext directly, and Control is the belt-and-braces check that the address was not silently swapped.

Types

type ClientOpts

type ClientOpts struct {
	Target          *Target
	FollowRedirects bool
	MaxRedirects    int
	MaxBodyBytes    int64 // 0 disables the cap
	Timeout         time.Duration
	DialTimeout     time.Duration

	// TLSConfig overrides the default. Default behaviour is
	// InsecureSkipVerify=true with SNI set to Target.Host — appropriate for
	// the header and custom-check probes, which must succeed even against
	// servers with bad certificates (the TLS probe grades those separately).
	TLSConfig *tls.Config
}

ClientOpts configures NewClient. Zero values are filled with conservative defaults — the only required field is Target.

type InputPolicy

type InputPolicy struct {
	AllowedSchemes   []string
	AllowCustomPorts bool
	DefaultPort      int // typically 443
}

InputPolicy captures the runtime knobs applied during validation.

type Limiter

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

Limiter is a per-key token-bucket limiter. Buckets are materialised on the first call for a given key and live for the lifetime of the process — acceptable in v1 (a future janitor can evict idle buckets if it becomes a memory pressure point).

func NewLimiter

func NewLimiter(count int, period time.Duration) *Limiter

NewLimiter returns a Limiter that allows up to count events per period per distinct key. A zero or negative count yields a permissive limiter that always allows.

func (*Limiter) Allow

func (l *Limiter) Allow(key string) bool

Allow consumes one token for key and reports whether the action is allowed.

type LookupFunc

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

LookupFunc resolves a host to a list of IP addresses. The default implementation delegates to net.DefaultResolver.LookupNetIP, but tests (and future caching layers) can inject a custom function.

type Policy

type Policy struct {
	// AllowPrivate, when true, lifts the block on RFC1918 + extra reserved
	// ranges. Loopback, link-local, multicast and unspecified remain blocked
	// because connecting to them never makes sense for a remote scanner.
	AllowPrivate bool

	// Extra is the operator-supplied prefix list. These are always blocked,
	// even when AllowPrivate is true — they express "things I never want
	// to scan from this instance".
	Extra []netip.Prefix
}

Policy is the runtime decision table for accepting or rejecting a target IP.

func (Policy) IsBlocked

func (p Policy) IsBlocked(ip netip.Addr) bool

IsBlocked reports whether ip must be refused under this policy. IPv4-mapped IPv6 addresses are unwrapped before evaluation.

type Resolver

type Resolver struct {
	Lookup LookupFunc // nil → DefaultLookup
	Policy Policy
}

Resolver owns the single-lookup contract: it picks the first IP returned by the resolver that satisfies the policy, and locks the result for the rest of the scan.

func (*Resolver) Resolve

func (r *Resolver) Resolve(ctx context.Context, v *Validated) (*Target, error)

Resolve performs one DNS lookup and returns a Target pinned to the first allowed IP. Errors are typed:

  • ErrNoAllowedIP when DNS returned zero addresses.
  • ErrPrivateTargetBlocked when every returned address is policy-blocked.

type Target

type Target struct {
	Scheme string
	Host   string // FQDN, lowercase, ASCII
	Port   int
	IP     netip.Addr
	// contains filtered or unexported fields
}

Target is the resolved, validated, IP-pinned destination of a scan. Construction goes exclusively through Resolver.Resolve.

func NewTarget

func NewTarget(scheme, host string, port int, ip netip.Addr) (*Target, error)

NewTarget builds a Target directly from already-resolved inputs. It bypasses DNS lookup and the IP policy check — callers must have verified the IP against Policy.IsBlocked themselves (or be writing tests).

The returned Target carries the same pinning guarantees as one produced by Resolver.Resolve: all subsequent PinnedDialer connections must hit ip.

func (*Target) AddrPort

func (t *Target) AddrPort() netip.AddrPort

AddrPort returns the pinned IP+port as a netip.AddrPort.

func (*Target) Address

func (t *Target) Address() string

Address returns the IP:port string the dialer must connect to.

func (*Target) HostPort

func (t *Target) HostPort() string

HostPort returns the canonical "host:port" used for SNI and the Host header. When Port is the scheme default (443 for https), the port is omitted to keep the form RFC-friendly.

func (*Target) URL

func (t *Target) URL(path string) string

URL builds a fully-qualified URL for the given absolute path.

type Validated

type Validated struct {
	Scheme string
	Host   string // lowercase ASCII FQDN
	Port   int
}

Validated is the canonical, accepted form of a user-supplied target.

func ValidateInput

func ValidateInput(raw string, p InputPolicy) (*Validated, error)

ValidateInput parses raw, applies, and returns the canonical components — or a typed error from this package.

Jump to

Keyboard shortcuts

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