All checks were successful
check / check (push) Successful in 5m25s
The SSRF blocklist had no escape hatch, so the thing webhooker is mostly for — taking a public webhook and forwarding it to something on your own network — could not be configured at all. Every private address, Docker sibling and loopback service was permanently unreachable as a delivery destination. ALLOWED_EGRESS_CIDRS (default empty) names blocks that delivery targets may reach despite the default blocklist. It is an allowlist and only ever adds destinations: there is no boolean, and no value disables SSRF protection wholesale. Empty, the guard behaves exactly as before. Link-local (169.254.0.0/16, fe80::/10) is refused before the allowlist is consulted, so no supplied CIDR can open it — not the exact address, not a supernet, not 0.0.0.0/0. Reaching cloud instance metadata is credential theft rather than delivery to an internal service. The policy now lives in one function, Guard.checkIP, which both target-creation validation and the delivery dialer call. The two paths previously decided separately, which is how they came to disagree about a destination. The guard is built once from config and injected via fx into both the handlers and the delivery engine, so there is a single instance and a single answer. A set-but-unparseable value aborts startup naming the variable, reusing the existing envPrefixList parser. A non-empty list is logged at startup with the blocks spelled out, not counted, so the hole is visible in the log of any deployment that has one. Tests: an allowlisted loopback CIDR both validates and delivers to a live server (and the same URL still fails without the allowlist); a private address outside the listed block stays refused on both paths; metadata stays refused under six different covering CIDRs; public addresses are unaffected either way; and config coverage for parsing, startup abort, and the warning's contents.
336 lines
7.8 KiB
Go
336 lines
7.8 KiB
Go
package delivery
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"time"
|
|
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
)
|
|
|
|
const (
|
|
// dnsResolutionTimeout is the maximum time to wait for
|
|
// DNS resolution during SSRF validation.
|
|
dnsResolutionTimeout = 5 * time.Second
|
|
)
|
|
|
|
// Sentinel errors for SSRF validation.
|
|
var (
|
|
errNoHostname = errors.New("URL has no hostname")
|
|
errNoIPs = errors.New(
|
|
"hostname resolved to no IP addresses",
|
|
)
|
|
errBlockedIP = errors.New(
|
|
"blocked private/reserved IP range",
|
|
)
|
|
errBlockedLinkLocal = errors.New(
|
|
"blocked link-local range, which serves cloud instance " +
|
|
"metadata: ALLOWED_EGRESS_CIDRS cannot open it",
|
|
)
|
|
errInvalidScheme = errors.New(
|
|
"only http and https are allowed",
|
|
)
|
|
)
|
|
|
|
// blockedNetworks contains all private/reserved IP ranges
|
|
// that should be blocked to prevent SSRF attacks. An operator
|
|
// can permit specific blocks out of this set with
|
|
// ALLOWED_EGRESS_CIDRS; see Guard.
|
|
//
|
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
|
var blockedNetworks []*net.IPNet
|
|
|
|
// alwaysBlockedNetworks are the ranges no configuration can
|
|
// open. They are the link-local blocks, which carry the cloud
|
|
// instance metadata services (169.254.169.254 and its IPv6
|
|
// equivalents). Reaching one is credential theft rather than
|
|
// delivery to an internal service, so a supplied CIDR that
|
|
// covers a link-local address still leaves it blocked.
|
|
//
|
|
// These addresses are also in blockedNetworks; this list is what
|
|
// makes them unconditional.
|
|
//
|
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
|
var alwaysBlockedNetworks []*net.IPNet
|
|
|
|
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
|
func init() {
|
|
blockedNetworks = mustParseCIDRs([]string{
|
|
"127.0.0.0/8",
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
"169.254.0.0/16",
|
|
"0.0.0.0/8",
|
|
"100.64.0.0/10",
|
|
"192.0.0.0/24",
|
|
"192.0.2.0/24",
|
|
"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",
|
|
"::1/128",
|
|
"fc00::/7",
|
|
"fe80::/10",
|
|
})
|
|
|
|
alwaysBlockedNetworks = mustParseCIDRs([]string{
|
|
"169.254.0.0/16",
|
|
"fe80::/10",
|
|
})
|
|
}
|
|
|
|
// mustParseCIDRs parses a list of CIDR literals, panicking on a
|
|
// bad one. The inputs are compile-time constants, so a failure
|
|
// is a programming error rather than a runtime condition.
|
|
func mustParseCIDRs(cidrs []string) []*net.IPNet {
|
|
networks := make([]*net.IPNet, 0, len(cidrs))
|
|
|
|
for _, cidr := range cidrs {
|
|
_, network, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
panic(fmt.Sprintf(
|
|
"ssrf: failed to parse CIDR %q: %v",
|
|
cidr, err,
|
|
))
|
|
}
|
|
|
|
networks = append(networks, network)
|
|
}
|
|
|
|
return networks
|
|
}
|
|
|
|
// matchesAny reports whether ip falls inside any of networks.
|
|
func matchesAny(networks []*net.IPNet, ip net.IP) bool {
|
|
for _, network := range networks {
|
|
if network.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isBlockedIP checks whether an IP address falls within
|
|
// any blocked private/reserved network range, before any
|
|
// operator allowlist is considered.
|
|
func isBlockedIP(ip net.IP) bool {
|
|
return matchesAny(blockedNetworks, ip)
|
|
}
|
|
|
|
// Guard makes every SSRF decision in the process.
|
|
//
|
|
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
|
|
// applies it in exactly one place, checkIP, which both the
|
|
// target-creation validator (ValidateTargetURL) and the delivery
|
|
// dialer call. Routing both through the same function is the
|
|
// point: when the two paths decided separately they drifted and
|
|
// disagreed, which is what made a target creatable but
|
|
// undeliverable.
|
|
//
|
|
// The guard is always on. The allowlist only ever adds specific
|
|
// networks to what the default blocklist refuses, and no
|
|
// configuration turns the guard off wholesale.
|
|
type Guard struct {
|
|
// allowed is the operator's ALLOWED_EGRESS_CIDRS. Empty
|
|
// (the default) means the default blocklist stands as-is.
|
|
allowed []netip.Prefix
|
|
}
|
|
|
|
// NewGuard builds the process-wide SSRF guard from configuration.
|
|
func NewGuard(cfg *config.Config) *Guard {
|
|
return &Guard{allowed: cfg.AllowedEgressCIDRs}
|
|
}
|
|
|
|
// ValidateTargetURL checks that an HTTP delivery target
|
|
// URL is safe from SSRF attacks.
|
|
func (g *Guard) ValidateTargetURL(
|
|
ctx context.Context, targetURL string,
|
|
) error {
|
|
parsed, err := url.Parse(targetURL)
|
|
if err != nil {
|
|
// url.Parse embeds the whole URL in its error, and
|
|
// this one is logged and shown; mask it. Every other
|
|
// branch below reports only the hostname.
|
|
return fmt.Errorf(
|
|
"invalid URL: %w", maskURLError(err),
|
|
)
|
|
}
|
|
|
|
err = validateScheme(parsed.Scheme)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
return errNoHostname
|
|
}
|
|
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
return g.checkIP(ip)
|
|
}
|
|
|
|
return g.validateHostname(ctx, host)
|
|
}
|
|
|
|
// NewSSRFSafeTransport creates an http.Transport with a
|
|
// custom DialContext that refuses connections to any address
|
|
// this guard blocks. It resolves and checks at dial time, so a
|
|
// name that passed validation but now answers with a blocked
|
|
// address (DNS rebinding) is still refused.
|
|
func (g *Guard) NewSSRFSafeTransport() *http.Transport {
|
|
return &http.Transport{
|
|
DialContext: g.ssrfDialContext,
|
|
}
|
|
}
|
|
|
|
// allows reports whether ip falls inside the operator's
|
|
// configured egress allowlist.
|
|
func (g *Guard) allows(ip net.IP) bool {
|
|
if len(g.allowed) == 0 {
|
|
return false
|
|
}
|
|
|
|
addr, ok := netip.AddrFromSlice(ip)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
// Config unmaps every parsed prefix, so an IPv4-mapped
|
|
// address has to be unmapped too or it would never match.
|
|
addr = addr.Unmap()
|
|
|
|
for _, prefix := range g.allowed {
|
|
if prefix.Contains(addr) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// checkIP is the single point at which SSRF policy is decided.
|
|
//
|
|
// The order is the policy:
|
|
//
|
|
// 1. Link-local is refused before the allowlist is consulted,
|
|
// so no configured CIDR can reach cloud instance metadata.
|
|
// 2. The allowlist is consulted next, so a listed private
|
|
// network becomes reachable.
|
|
// 3. Everything else keeps the default blocklist's answer.
|
|
func (g *Guard) checkIP(ip net.IP) error {
|
|
if matchesAny(alwaysBlockedNetworks, ip) {
|
|
return fmt.Errorf(
|
|
"target IP %s: %w", ip, errBlockedLinkLocal,
|
|
)
|
|
}
|
|
|
|
if g.allows(ip) {
|
|
return nil
|
|
}
|
|
|
|
if isBlockedIP(ip) {
|
|
return fmt.Errorf(
|
|
"target IP %s: %w", ip, errBlockedIP,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *Guard) validateHostname(
|
|
ctx context.Context, host string,
|
|
) error {
|
|
dnsCtx, cancel := context.WithTimeout(
|
|
ctx, dnsResolutionTimeout,
|
|
)
|
|
defer cancel()
|
|
|
|
ips, err := net.DefaultResolver.LookupIPAddr(
|
|
dnsCtx, host,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to resolve hostname %q: %w",
|
|
host, err,
|
|
)
|
|
}
|
|
|
|
if len(ips) == 0 {
|
|
return fmt.Errorf(
|
|
"hostname %q: %w", host, errNoIPs,
|
|
)
|
|
}
|
|
|
|
for _, ipAddr := range ips {
|
|
err = g.checkIP(ipAddr.IP)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"hostname %q resolves to a blocked address: %w",
|
|
host, err,
|
|
)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *Guard) ssrfDialContext(
|
|
ctx context.Context,
|
|
network, addr string,
|
|
) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"ssrf: invalid address %q: %w",
|
|
addr, err,
|
|
)
|
|
}
|
|
|
|
ips, err := net.DefaultResolver.LookupIPAddr(
|
|
ctx, host,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"ssrf: DNS resolution failed for %q: %w",
|
|
host, err,
|
|
)
|
|
}
|
|
|
|
for _, ipAddr := range ips {
|
|
err = g.checkIP(ipAddr.IP)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"ssrf: connection to %s blocked: %w",
|
|
host, err,
|
|
)
|
|
}
|
|
}
|
|
|
|
var dialer net.Dialer
|
|
|
|
return dialer.DialContext(
|
|
ctx, network,
|
|
net.JoinHostPort(ips[0].IP.String(), port),
|
|
)
|
|
}
|
|
|
|
func validateScheme(scheme string) error {
|
|
if scheme != "http" && scheme != "https" {
|
|
return fmt.Errorf(
|
|
"unsupported URL scheme %q: %w",
|
|
scheme, errInvalidScheme,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|