safeclient

package
v0.106.8-alpha.0 Latest Latest
Warning

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

Go to latest
Published: Sep 9, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package safeclient provides an SSRF-hardened HTTP client for delivering outbound requests to user-supplied endpoints. It is built on github.com/doyensec/safeurl, which validates the resolved IP at connection time (so it is safe against DNS rebinding), and layers on an explicit, auditable denylist plus synchronous pre-checks.

Policy enforced by this package:

  • only scheme https, only port 443;
  • all internal/reserved IP space is blocked, plus any caller-supplied Config.InfraBlockedCIDRs;
  • redirects are never followed (3xx is surfaced to the caller as the result status);
  • the response body is capped at Config.MaxResponseBytes, and reading stops there;
  • idle connections are bounded globally, per host and in time (Config.MaxIdleConns, MaxIdleConnsPerHost, IdleConnTimeout); CloseIdleConnections releases them;
  • transport errors are returned as EndpointError, worded for the tenant, with the detail in the operator log;
  • the overall request deadline is owned by the CALLER via context.Context — this package imposes no overall request timeout.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrBlockedDestination indicates the destination resolved to (or is) a blocked IP,
	// or otherwise violated the destination policy (credentials in URL, invalid host).
	ErrBlockedDestination = errors.New("safeclient: destination blocked by SSRF policy")

	// ErrBadScheme indicates a scheme other than https.
	ErrBadScheme = errors.New("safeclient: scheme not allowed (https only)")

	// ErrBadPort indicates a port other than 443.
	ErrBadPort = errors.New("safeclient: port not allowed (443 only)")

	// ErrResponseTooLarge indicates the response body exceeded Config.MaxResponseBytes.
	ErrResponseTooLarge = errors.New("safeclient: response body exceeded maximum size")
)

Typed errors returned by the client. Callers (e.g. a retry queue or API validation layer) use errors.Is to distinguish policy-blocked failures — which must NOT be retried and should be surfaced to the user — from transient network failures, which are retryable. Any error returned from Deliver that does not match one of these (nor a context error) should be treated as a transient/retryable network failure.

View Source
var DefaultBlockedCIDRs = []string{
	"0.0.0.0/8",
	"10.0.0.0/8",
	"100.64.0.0/10",
	"127.0.0.0/8",
	"169.254.0.0/16",
	"172.16.0.0/12",
	"192.0.0.0/24",
	"192.0.2.0/24",
	"192.88.99.0/24",
	"192.168.0.0/16",
	"198.18.0.0/15",
	"198.51.100.0/24",
	"203.0.113.0/24",
	"224.0.0.0/4",
	"240.0.0.0/4",
	"255.255.255.255/32",
	"::/128",
	"::1/128",
	"64:ff9b::/96",
	"fc00::/7",
	"fe80::/10",
	"ff00::/8",
}

DefaultBlockedCIDRs is the always-applied denylist of internal/reserved IP space. It is declared explicitly (rather than relying on safeurl's library defaults) so the SSRF policy is auditable in one place and cannot drift with library internals.

NOTE: this list intentionally contains ONLY well-known reserved/private/documentation ranges. Real infrastructure CIDRs (VPC, EKS pod/service ranges, internal load balancers, etc.) must never be hardcoded here — they are supplied at runtime via Config.InfraBlockedCIDRs. The cloud metadata endpoint (169.254.169.254) is already covered by the link-local 169.254.0.0/16 range below.

Functions

func PublicError

func PublicError(host string, err error) error

PublicError wraps a transport error for the tenant (see EndpointError). Policy errors and errors that are already public are returned as they are; nil stays nil.

func ValidateEndpoint

func ValidateEndpoint(rawURL string) error

ValidateEndpoint runs the synchronous, network-free scheme/port/userinfo checks against a raw URL. It is exposed for reuse at the endpoint-registration API boundary so invalid URLs can be rejected with a clear message at submission time.

This is a UX convenience only: the dial-time IP check in Deliver remains the real enforcement point. Do NOT pre-resolve DNS and store the resolved IP — resolution must happen fresh on every delivery attempt.

Types

type Config

type Config struct {
	InfraBlockedCIDRs []string

	ConnectTimeout   time.Duration
	MaxResponseBytes int64

	// MaxIdleConns, MaxIdleConnsPerHost and IdleConnTimeout bound the transport's idle
	// connection pool; zero values take the package defaults (256, 4, 90s).
	MaxIdleConns        int
	MaxIdleConnsPerHost int
	IdleConnTimeout     time.Duration

	MaxRedirects         int
	AllowEmptyInfraCIDRs bool
	EnableIPv6           bool

	// InsecureDestinations disables the SSRF policy for local development and e2e runs:
	// plain http, any port, and loopback and private ranges are all allowed, and the
	// request goes through a plain http.Client instead of safeurl. TLS verification stays
	// on. It is only honored when set explicitly by the operator's own configuration;
	// nothing in this package turns it on.
	InsecureDestinations bool
	// contains filtered or unexported fields
}

Config controls the SSRF policy and resource limits of a Sender.

type DeliveryResult

type DeliveryResult struct {
	BodyPrefix []byte
	StatusCode int
	Duration   time.Duration
}

DeliveryResult is the outcome of a successful (network-completed) delivery attempt. A 3xx status is reported here, not followed.

type Dialer

type Dialer interface {
	DialContext(ctx context.Context, network, addr string) (net.Conn, error)
}

Dialer is the network seam a Sender exposes for protocols that are not plain HTTP, such as the serverless operator's durable websocket. It has the shape of net.Dialer.DialContext so it drops into websocket and other libraries' NetDialContext hooks.

type EndpointError

type EndpointError struct {
	Host  string
	Stage Stage
	// contains filtered or unexported fields
}

EndpointError is a transport failure on the way to an endpoint, worded for the tenant: it names the endpoint host and the stage that failed and nothing else, because the message ends up in task errors and endpoint status. Resolved addresses, ports, resolver and socket detail stay in the operator log, where the Sender writes them. The cause is still reachable through errors.Is and errors.As for classification (context errors, net.Error timeouts).

func (*EndpointError) Error

func (e *EndpointError) Error() string

func (*EndpointError) Unwrap

func (e *EndpointError) Unwrap() error

type Sender

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

Sender delivers outbound HTTP requests under the SSRF policy. Construct one with New and reuse it; it is safe for concurrent use.

func New

func New(cfg Config, l *zerolog.Logger) (*Sender, error)

New validates cfg, builds the safeurl-backed client, and returns a Sender. It fails if InfraBlockedCIDRs is empty (unless AllowEmptyInfraCIDRs) or if any blocked CIDR — default or infra — fails to parse. l may be nil (logging is then disabled).

func (*Sender) CloseIdleConnections

func (s *Sender) CloseIdleConnections()

CloseIdleConnections drops every pooled connection. Call it when the Sender is retired or when the origins it served are gone.

func (*Sender) Deliver

func (s *Sender) Deliver(ctx context.Context, method, endpoint string, body []byte, headers http.Header) (*DeliveryResult, error)

Deliver issues a request with the given method, body, and headers to endpoint, enforcing the full SSRF policy. The HTTP method is chosen by the caller (e.g. http.MethodPost, http.MethodGet). It never follows redirects and caps the response body at the configured maximum.

The overall timeout/deadline is owned by the CALLER via ctx: this method sets no http.Client.Timeout and wraps no internal context. If ctx has no deadline, the request may run unbounded — callers should set a deadline.

Retries: callers must re-invoke Deliver for each attempt so that full validation — including fresh DNS resolution and the dial-time IP check — runs every time. Never cache a resolved IP across attempts.

It returns a *DeliveryResult on a completed request (including 3xx), or a typed error: ErrBadScheme, ErrBadPort, ErrBlockedDestination (do not retry — surface to the user), ErrResponseTooLarge, or an *EndpointError wrapping the context/network error (retryable). Use errors.Is to distinguish them; an EndpointError's message is safe to show the tenant and the wrapped detail is logged here.

func (*Sender) DialContext

func (s *Sender) DialContext(ctx context.Context, network, addr string) (net.Conn, error)

DialContext opens a TCP connection to addr (host:port) under the same SSRF policy the HTTP path enforces: the port must be allowed, the host is resolved fresh on every call, every resolved address is checked against the blocklist, IPv6 is skipped unless enabled, and the connection is made to the validated IP, never to the name, so a DNS answer cannot change between the check and the connect. Under InsecureDestinations the policy is off and a plain dial is made.

TLS is not negotiated here; the caller layers it on top with the original host name so certificate verification is unaffected by dialing the IP.

func (*Sender) InsecureDestinations

func (s *Sender) InsecureDestinations() bool

InsecureDestinations reports whether the Sender was built with the development-only policy off, so callers layering other protocols on DialContext (the durable websocket relay) can relax their own scheme checks in step.

type Stage

type Stage string

Stage is the step of an outbound request at which a transport failure happened.

const (
	StageResolve Stage = "resolve"
	StageConnect Stage = "connect"
	StageTLS     Stage = "tls"
	StageRead    Stage = "read"
	StageRequest Stage = "request"
)

Jump to

Keyboard shortcuts

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