Files
at-container-registry/pkg/appview/webhooks/ssrf.go
T
Evan JarrettandClaude Opus 5 39919cc832 appview: stop webhooks reaching private addresses
The URL check accepted http:// while telling the user "must be https", and
guarded no addresses at all. POST /api/webhooks with http://127.0.0.1:9/hook
returned 200 and created the webhook, so both scheduled deliveries and the
synchronous Test button would dial arbitrary destinations from the appview
host, on demand, for any authenticated user. Loopback, link-local (including
the cloud metadata endpoint at 169.254.169.254) and RFC1918 were all reachable.

Enforces https, and refuses non-public destinations.

The load-bearing half is the dial-time check, not the creation-time one. An
attacker controls their own DNS, so a hostname that resolves publicly when the
webhook is created can resolve to loopback when it is delivered, and a
creation-time check cannot see a redirect either. The guard is therefore a
net.Dialer Control hook on the delivery client, which inspects the resolved
address on every connection attempt. Transport.Proxy is explicitly nil:
honouring HTTP(S)_PROXY would route around the Control hook and hand the
bypass straight back. Redirects are re-validated per hop and capped at 3.

The creation-time check stays so the user gets an immediate, comprehensible
error instead of a silent delivery failure later.

IPv4-mapped IPv6 is unmapped before every check, so ::ffff:127.0.0.1 and
friends hit the IPv4 rules. Ranges with no net.IP helper are listed explicitly:
CGNAT, NAT64, ::/96, TEST-NET and reserved space.

Both outbound paths are covered, since the scheduled dispatcher and the Test
button both funnel through attemptDelivery. The dispatcher's other client is
deliberately left unguarded: it fetches quota stats from holds, which
legitimately live on private addresses, and those URLs are not user-supplied.

Note this removes the ability to point a webhook at a localhost receiver in
local development. There is deliberately no environment-variable escape hatch,
since a security toggle read from the environment is the same bypass wearing a
nicer coat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 21:37:19 -05:00

237 lines
8.3 KiB
Go

package webhooks
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"syscall"
"time"
)
// Webhook URLs are attacker-supplied: any authenticated user can register one
// and then make the appview dial it on demand with the Test button. Without a
// destination guard that is a server-side request forgery primitive against
// everything the appview host can reach, including the cloud metadata endpoint
// at 169.254.169.254 and anything bound to loopback.
//
// The guard has two layers:
//
// 1. ValidateWebhookURL, applied at creation time and again before every
// delivery attempt. It enforces https and rejects URLs that name a
// non-public IP literal. Its real job is giving the user an immediate,
// comprehensible error instead of a mysterious delivery failure later.
//
// 2. A net.Dialer Control hook on the delivery HTTP client, which inspects the
// *resolved* address on every connection attempt. This is the layer that
// actually holds: the attacker controls DNS for their own hostname, so a
// name that resolves public at creation can resolve to 127.0.0.1 at
// delivery (DNS rebinding), and creation-time validation cannot see a
// redirect target at all.
//
// Both the scheduled dispatcher and the synchronous Test button deliver through
// attemptDelivery, which uses the guarded client, so neither path can dial a
// private address.
// User-facing validation failures. The messages are written to be shown
// verbatim in the settings UI after an "Invalid webhook URL: " prefix.
var (
// ErrMalformedURL means the string did not parse as an absolute URL.
ErrMalformedURL = errors.New("could not be parsed as a URL")
// ErrSchemeNotHTTPS means the URL used something other than https.
// Webhook payloads carry repository names and HMAC signatures, so plaintext
// http is refused outright rather than merely discouraged.
ErrSchemeNotHTTPS = errors.New("must be https")
// ErrPrivateAddress means the URL resolved to, or literally named, an
// address that is not publicly routable.
ErrPrivateAddress = errors.New("cannot point at a private or loopback address")
)
// extraBlocked covers ranges that have no net.IP helper. Everything with a
// stdlib predicate (loopback, RFC1918 + IPv6 ULA, link-local, multicast,
// unspecified, broadcast) is handled by blockedReason instead of being
// re-derived here.
var extraBlocked = []struct {
net *net.IPNet
reason string
}{
{mustCIDR("100.64.0.0/10"), "carrier-grade NAT address"},
{mustCIDR("192.0.0.0/24"), "IETF protocol assignment address"},
{mustCIDR("198.18.0.0/15"), "benchmarking address"},
{mustCIDR("240.0.0.0/4"), "reserved address"},
// IPv4-compatible IPv6 (::a.b.c.d). To4 does not unmap these, and
// IsLoopback on ::127.0.0.1 is false, so they need an explicit range.
{mustCIDR("::/96"), "IPv4-compatible IPv6 address"},
{mustCIDR("64:ff9b::/96"), "NAT64 address"},
{mustCIDR("64:ff9b:1::/48"), "local-use NAT64 address"},
{mustCIDR("100::/64"), "discard-only address"},
{mustCIDR("2001:db8::/32"), "documentation address"},
}
func mustCIDR(s string) *net.IPNet {
_, n, err := net.ParseCIDR(s)
if err != nil {
panic("webhooks: bad CIDR " + s + ": " + err.Error())
}
return n
}
// blockedReason reports why an address must not be dialed, or "" if it is a
// public destination. IPv4-mapped IPv6 forms (::ffff:127.0.0.1) are unmapped
// first, so every IPv4 rule below applies to them too.
func blockedReason(ip net.IP) string {
if ip == nil {
return "not a valid IP address"
}
if v4 := ip.To4(); v4 != nil {
ip = v4
}
switch {
case ip.IsUnspecified():
return "unspecified address"
case ip.IsLoopback():
// 127.0.0.0/8 and ::1
return "loopback address"
case ip.IsPrivate():
// 10/8, 172.16/12, 192.168/16 and IPv6 unique-local fc00::/7
return "private address"
case ip.IsLinkLocalUnicast():
// 169.254/16 (covers the 169.254.169.254 metadata endpoint) and fe80::/10
return "link-local address"
case ip.IsInterfaceLocalMulticast(), ip.IsLinkLocalMulticast(), ip.IsMulticast():
return "multicast address"
case !ip.IsGlobalUnicast():
// Catches the IPv4 broadcast address and anything else the stdlib does
// not consider globally routable.
return "non-global address"
}
for _, b := range extraBlocked {
if b.net.Contains(ip) {
return b.reason
}
}
return ""
}
// ValidateWebhookURL checks a user-supplied webhook URL at rest: https scheme,
// a host, and no non-public IP literal. It does not resolve DNS. A hostname
// that resolves to a private address is caught at dial time by the Control hook
// instead, which is the only check an attacker who controls DNS cannot dodge.
func ValidateWebhookURL(raw string) error {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ErrMalformedURL
}
if !strings.EqualFold(u.Scheme, "https") {
return ErrSchemeNotHTTPS
}
return validateParsedURL(u)
}
// validateParsedURL is the scheme + host check applied both at creation and to
// every redirect hop.
func validateParsedURL(u *url.URL) error {
if !strings.EqualFold(u.Scheme, "https") {
return ErrSchemeNotHTTPS
}
host := u.Hostname()
if host == "" {
return ErrMalformedURL
}
if ip := net.ParseIP(host); ip != nil {
if reason := blockedReason(ip); reason != "" {
return fmt.Errorf("%w (%s is a %s)", ErrPrivateAddress, host, reason)
}
return nil
}
// Names are resolved at dial time, but reject the obvious ones up front so
// the user gets a real message instead of a delivery that just never works.
lower := strings.ToLower(host)
if lower == "localhost" || strings.HasSuffix(lower, ".localhost") {
return fmt.Errorf("%w (%s is a loopback name)", ErrPrivateAddress, host)
}
return nil
}
// checkDialAddr inspects a resolved "host:port" address just before the socket
// is connected. This runs for every attempt, every redirect hop, and every
// address the resolver returns, which is what makes DNS rebinding ineffective.
func checkDialAddr(network, address string) error {
switch network {
case "tcp", "tcp4", "tcp6":
default:
return fmt.Errorf("%w (network %q is not allowed)", ErrPrivateAddress, network)
}
host, _, err := net.SplitHostPort(address)
if err != nil {
host = address
}
ip := net.ParseIP(host)
if ip == nil {
// Control receives an already-resolved literal. Anything else is
// unexpected, so refuse rather than guess.
return fmt.Errorf("%w (%q did not resolve to an IP)", ErrPrivateAddress, address)
}
if reason := blockedReason(ip); reason != "" {
return fmt.Errorf("%w (%s is a %s)", ErrPrivateAddress, ip, reason)
}
return nil
}
// safeDialControl is the net.Dialer Control hook. The signature is fixed by
// net.Dialer.
func safeDialControl(network, address string, _ syscall.RawConn) error {
return checkDialAddr(network, address)
}
// maxRedirects bounds how far a webhook endpoint can bounce us. Each hop is
// re-validated, and the dial guard applies to every hop regardless.
const maxRedirects = 3
// NewSafeClient builds the HTTP client used for delivering webhook payloads to
// user-supplied URLs. It must not be reused for talking to holds or any other
// internal service, which may legitimately live on a private address.
func NewSafeClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
Control: safeDialControl,
}
transport := &http.Transport{
// Deliberately no Proxy: honoring HTTP(S)_PROXY would route the request
// through a proxy that the Control hook cannot see past, handing the
// bypass straight back.
Proxy: nil,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: timeout,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Timeout: timeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
// A public https endpoint that 302s to http://169.254.169.254/ is
// refused here on scheme alone; the dial guard would refuse the
// address anyway.
if err := validateParsedURL(req.URL); err != nil {
return fmt.Errorf("refusing webhook redirect to %s: %w", req.URL.Redacted(), err)
}
return nil
},
}
}