All checks were successful
check / check (push) Successful in 3m30s
A delivery target URL is itself a credential: a Slack incoming webhook URL is a bearer token. Three paths still reproduced it in full. Transport failures were the worst of them. net/http embeds the request URL in every *url.Error it returns, so any DNS, TLS, timeout or dial failure wrote the whole webhook URL into DeliveryResult.Error — on disk, in the per-webhook database, behind a json tag that a REST API would serialize. maskURL moves to url_mask.go and is exported as MaskURL, and maskURLError joins it: it rebuilds the *url.Error with the URL masked, keeping the operation and the wrapped cause, so a refused connection still reads differently from a DNS failure or a timeout and errors.Is/As/Timeout still work. It is applied where the errors are raised — executeHTTPRequest, shared by the Slack and HTTP targets, and the request-construction paths — so downstream wrapping is safe by construction. url.Parse embeds the URL too, so ValidateTargetURL's parse branch gets the same treatment; its error is logged and shown. The SSRF rejection log now records only the masked URL, and loadTargetMap hands the event log page TargetViews and a delivery projection instead of raw target rows, so the stored config blob has no path to that template either.
228 lines
4.3 KiB
Go
228 lines
4.3 KiB
Go
package delivery
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
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",
|
|
)
|
|
errInvalidScheme = errors.New(
|
|
"only http and https are allowed",
|
|
)
|
|
)
|
|
|
|
// blockedNetworks contains all private/reserved IP ranges
|
|
// that should be blocked to prevent SSRF attacks.
|
|
//
|
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
|
var blockedNetworks []*net.IPNet
|
|
|
|
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
|
func init() {
|
|
cidrs := []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",
|
|
}
|
|
|
|
for _, cidr := range cidrs {
|
|
_, network, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
panic(fmt.Sprintf(
|
|
"ssrf: failed to parse CIDR %q: %v",
|
|
cidr, err,
|
|
))
|
|
}
|
|
|
|
blockedNetworks = append(
|
|
blockedNetworks, network,
|
|
)
|
|
}
|
|
}
|
|
|
|
// isBlockedIP checks whether an IP address falls within
|
|
// any blocked private/reserved network range.
|
|
func isBlockedIP(ip net.IP) bool {
|
|
for _, network := range blockedNetworks {
|
|
if network.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ValidateTargetURL checks that an HTTP delivery target
|
|
// URL is safe from SSRF attacks.
|
|
func 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 checkBlockedIP(ip)
|
|
}
|
|
|
|
return validateHostname(ctx, host)
|
|
}
|
|
|
|
func validateScheme(scheme string) error {
|
|
if scheme != "http" && scheme != "https" {
|
|
return fmt.Errorf(
|
|
"unsupported URL scheme %q: %w",
|
|
scheme, errInvalidScheme,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkBlockedIP(ip net.IP) error {
|
|
if isBlockedIP(ip) {
|
|
return fmt.Errorf(
|
|
"target IP %s is in a blocked "+
|
|
"private/reserved range: %w",
|
|
ip, errBlockedIP,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func 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 {
|
|
if isBlockedIP(ipAddr.IP) {
|
|
return fmt.Errorf(
|
|
"hostname %q resolves to blocked "+
|
|
"IP %s: %w",
|
|
host, ipAddr.IP, errBlockedIP,
|
|
)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// NewSSRFSafeTransport creates an http.Transport with a
|
|
// custom DialContext that blocks connections to
|
|
// private/reserved IP addresses.
|
|
func NewSSRFSafeTransport() *http.Transport {
|
|
return &http.Transport{
|
|
DialContext: ssrfDialContext,
|
|
}
|
|
}
|
|
|
|
func 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 {
|
|
if isBlockedIP(ipAddr.IP) {
|
|
return nil, fmt.Errorf(
|
|
"ssrf: connection to %s (%s) "+
|
|
"blocked: %w",
|
|
host, ipAddr.IP, errBlockedIP,
|
|
)
|
|
}
|
|
}
|
|
|
|
var dialer net.Dialer
|
|
|
|
return dialer.DialContext(
|
|
ctx, network,
|
|
net.JoinHostPort(ips[0].IP.String(), port),
|
|
)
|
|
}
|