Files
at-container-registry/pkg/appview/webhooks/ssrf_test.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

359 lines
12 KiB
Go

package webhooks
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
)
// Finding 35: the creation-time check claimed "must be https" while accepting
// http, and nothing anywhere looked at the destination address. Both the
// scheduled dispatcher and the synchronous Test button would dial whatever the
// user typed, including 127.0.0.1 and 169.254.169.254.
func TestValidateWebhookURL_Scheme(t *testing.T) {
tests := []struct {
name string
url string
wantErr error
}{
{"plain http is refused", "http://example.com/hook", ErrSchemeNotHTTPS},
{"http to a public host is still refused", "http://93.184.216.34/hook", ErrSchemeNotHTTPS},
{"uppercase scheme is still http", "HTTP://example.com/hook", ErrSchemeNotHTTPS},
{"no scheme", "example.com/hook", ErrSchemeNotHTTPS},
{"file scheme", "file:///etc/passwd", ErrSchemeNotHTTPS},
{"gopher scheme", "gopher://example.com/", ErrSchemeNotHTTPS},
{"https with no host", "https://", ErrMalformedURL},
{"public https host", "https://example.com/hook", nil},
{"public https host with port and path", "https://hooks.slack.com:443/services/T/B/x", nil},
{"public https IP literal", "https://93.184.216.34/hook", nil},
{"public https IPv6 literal", "https://[2606:2800:220:1:248:1893:25c8:1946]/hook", nil},
{"uppercase HTTPS is accepted", "HTTPS://example.com/hook", nil},
{"leading and trailing space tolerated", " https://example.com/hook ", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateWebhookURL(tt.url)
if tt.wantErr == nil {
if err != nil {
t.Fatalf("ValidateWebhookURL(%q) = %v, want nil", tt.url, err)
}
return
}
if !errors.Is(err, tt.wantErr) {
t.Fatalf("ValidateWebhookURL(%q) = %v, want %v", tt.url, err, tt.wantErr)
}
})
}
}
func TestValidateWebhookURL_PrivateLiterals(t *testing.T) {
urls := []string{
"https://127.0.0.1/hook",
"https://127.1.2.3:8443/hook",
"https://[::1]/hook",
"https://10.0.0.5/hook",
"https://172.16.0.1/hook",
"https://192.168.1.1/hook",
"https://169.254.169.254/latest/meta-data/",
"https://[fe80::1]/hook",
"https://[fd00::1]/hook",
"https://[::ffff:127.0.0.1]/hook",
"https://[::ffff:169.254.169.254]/hook",
"https://0.0.0.0/hook",
"https://localhost/hook",
"https://api.localhost/hook",
}
for _, u := range urls {
t.Run(u, func(t *testing.T) {
err := ValidateWebhookURL(u)
if !errors.Is(err, ErrPrivateAddress) {
t.Fatalf("ValidateWebhookURL(%q) = %v, want ErrPrivateAddress", u, err)
}
})
}
}
// blockedReason is the single decision the dial guard makes. Table-driven so a
// range that stops being blocked has to come past this test.
func TestBlockedReason(t *testing.T) {
tests := []struct {
ip string
blocked bool
}{
// Loopback
{"127.0.0.1", true},
{"127.255.255.254", true},
{"::1", true},
{"::ffff:127.0.0.1", true}, // IPv4-mapped IPv6
{"::127.0.0.1", true}, // IPv4-compatible IPv6
// RFC1918
{"10.0.0.1", true},
{"10.255.255.255", true},
{"172.16.0.1", true},
{"172.31.255.255", true},
{"192.168.0.1", true},
{"::ffff:10.0.0.1", true},
{"::ffff:192.168.1.1", true},
{"::ffff:172.20.0.1", true},
// Link-local, including the cloud metadata endpoint
{"169.254.169.254", true},
{"169.254.0.1", true},
{"::ffff:169.254.169.254", true},
{"fe80::1", true},
// IPv6 unique-local
{"fc00::1", true},
{"fd12:3456:789a::1", true},
// Unspecified / multicast / broadcast
{"0.0.0.0", true},
{"::", true},
{"224.0.0.1", true},
{"239.255.255.250", true},
{"ff02::1", true},
{"255.255.255.255", true},
// Ranges with no stdlib helper
{"100.64.0.1", true}, // CGNAT
{"192.0.0.1", true}, // IETF protocol assignments
{"198.18.0.1", true}, // benchmarking
{"240.0.0.1", true}, // reserved
{"64:ff9b::1", true}, // NAT64
{"100::1", true}, // discard-only
{"2001:db8::1", true}, // documentation
// Public destinations must keep working
{"93.184.216.34", false},
{"1.1.1.1", false},
{"8.8.8.8", false},
{"172.32.0.1", false}, // just outside 172.16/12
{"172.15.0.1", false}, // just below 172.16/12
{"100.63.255.255", false}, // just below CGNAT
{"100.128.0.1", false}, // just above CGNAT
{"169.253.0.1", false}, // just below link-local
{"2606:2800:220:1:248:1893:25c8:1946", false},
{"::ffff:93.184.216.34", false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("test bug: %q is not a valid IP", tt.ip)
}
reason := blockedReason(ip)
if tt.blocked && reason == "" {
t.Errorf("blockedReason(%s) allowed a non-public address", tt.ip)
}
if !tt.blocked && reason != "" {
t.Errorf("blockedReason(%s) blocked a public address: %s", tt.ip, reason)
}
})
}
}
func TestBlockedReason_NilIP(t *testing.T) {
if blockedReason(nil) == "" {
t.Error("blockedReason(nil) must not report the address as allowed")
}
}
// checkDialAddr is what the Control hook runs on the resolved address, so it is
// the check DNS rebinding has to get past.
func TestCheckDialAddr(t *testing.T) {
tests := []struct {
name string
network string
address string
blocked bool
}{
{"loopback v4", "tcp", "127.0.0.1:80", true},
{"loopback v6", "tcp", "[::1]:443", true},
{"rfc1918", "tcp4", "10.1.2.3:443", true},
{"link-local metadata", "tcp4", "169.254.169.254:80", true},
{"ipv4-mapped loopback", "tcp6", "[::ffff:127.0.0.1]:443", true},
{"ipv4-mapped rfc1918", "tcp6", "[::ffff:192.168.0.9]:443", true},
{"unix socket network", "unix", "/var/run/docker.sock", true},
{"unresolved name", "tcp", "example.com:443", true},
{"public v4", "tcp", "93.184.216.34:443", false},
{"public v6", "tcp6", "[2606:2800:220:1:248:1893:25c8:1946]:443", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkDialAddr(tt.network, tt.address)
if tt.blocked && err == nil {
t.Errorf("checkDialAddr(%q, %q) allowed the connection", tt.network, tt.address)
}
if !tt.blocked && err != nil {
t.Errorf("checkDialAddr(%q, %q) = %v, want nil", tt.network, tt.address, err)
}
})
}
}
// End to end: the guarded client must refuse a loopback server even when the
// URL is handed to it directly, which is what the Test button effectively does.
func TestSafeClient_RefusesLoopbackServer(t *testing.T) {
reached := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
client := NewSafeClient(5 * time.Second)
resp, err := client.Get(srv.URL) //nolint:noctx // short-lived guard check
if err == nil {
resp.Body.Close()
t.Fatal("guarded client connected to a loopback server")
}
if reached {
t.Fatal("request reached the loopback server despite the dial guard")
}
if !strings.Contains(err.Error(), ErrPrivateAddress.Error()) {
t.Errorf("error %v does not mention the private-address refusal", err)
}
}
// A public endpoint that redirects to the metadata address must not be
// followed. The redirect target is checked before the hop is made, and the dial
// guard would refuse the address anyway.
func TestSafeClient_RefusesRedirectToPrivate(t *testing.T) {
err := redirectCheck(t, "http://169.254.169.254/latest/meta-data/")
if err == nil {
t.Fatal("CheckRedirect followed a redirect to the metadata endpoint")
}
if !errors.Is(err, ErrSchemeNotHTTPS) && !errors.Is(err, ErrPrivateAddress) {
t.Errorf("unexpected refusal reason: %v", err)
}
err = redirectCheck(t, "https://127.0.0.1/hook")
if !errors.Is(err, ErrPrivateAddress) {
t.Errorf("https redirect to loopback: got %v, want ErrPrivateAddress", err)
}
if err := redirectCheck(t, "https://example.org/next"); err != nil {
t.Errorf("redirect to a public https URL was refused: %v", err)
}
}
// redirectCheck exercises the client's CheckRedirect hook directly; dialing a
// real redirect chain would need network access.
func redirectCheck(t *testing.T, target string) error {
t.Helper()
client := NewSafeClient(5 * time.Second)
req, err := http.NewRequest(http.MethodGet, target, nil) //nolint:noctx // not sent
if err != nil {
t.Fatalf("building request: %v", err)
}
via, err := http.NewRequest(http.MethodPost, "https://example.com/hook", nil) //nolint:noctx // not sent
if err != nil {
t.Fatalf("building via request: %v", err)
}
return client.CheckRedirect(req, []*http.Request{via})
}
func TestSafeClient_StopsRedirectLoop(t *testing.T) {
client := NewSafeClient(5 * time.Second)
req, err := http.NewRequest(http.MethodGet, "https://example.com/hook", nil) //nolint:noctx // not sent
if err != nil {
t.Fatalf("building request: %v", err)
}
via := make([]*http.Request, maxRedirects)
for i := range via {
via[i] = req
}
if err := client.CheckRedirect(req, via); err == nil {
t.Errorf("CheckRedirect allowed hop %d, want a stop after %d", len(via)+1, maxRedirects)
}
}
// The delivery path re-validates every attempt, so a row written before the
// guard (or by any path that skips the handler) is refused instead of dialed.
func TestAttemptDelivery_RefusesDisallowedURLs(t *testing.T) {
reached := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
d := &Dispatcher{deliveryClient: NewSafeClient(5 * time.Second)}
for _, u := range []string{srv.URL, "http://example.com/hook", "https://127.0.0.1:9/hook"} {
if d.attemptDelivery(u, "", []byte(`{}`)) {
t.Errorf("attemptDelivery(%q) reported success", u)
}
}
if reached {
t.Fatal("a delivery attempt reached the loopback server")
}
}
// allowLoopbackDeliveryForTest relaxes the destination guard so a test can
// deliver to its own httptest server. It exists only in the test build: there
// is no way to reach it from production code.
func (d *Dispatcher) allowLoopbackDeliveryForTest() {
d.validateURL = func(string) error { return nil }
d.deliveryClient = &http.Client{Timeout: deliveryTimeout}
}
// The Test button is the more dangerous of the two delivery paths: it is
// immediate and attacker-timed. DeliverTest must go through the same guard as
// the scheduled dispatcher.
func TestDeliverTest_RefusesLoopbackWebhook(t *testing.T) {
conn, err := db.InitDB(t.TempDir()+"/test.db", db.LibsqlConfig{})
if err != nil {
t.Fatalf("init db: %v", err)
}
defer conn.Close()
const userDID = "did:plc:ssrftester"
if err := db.UpsertUser(conn, &db.User{
DID: userDID, Handle: "ssrf.test", PDSEndpoint: "https://pds", LastSeen: time.Now(),
}); err != nil {
t.Fatalf("upsert user: %v", err)
}
reached := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// A row created before the guard existed, pointing at loopback over http.
if err := db.InsertWebhook(conn, &db.Webhook{
ID: "wh-loopback", UserDID: userDID, URL: srv.URL,
Triggers: PackTriggers(TriggerFirst, 0), CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("insert webhook: %v", err)
}
d := NewDispatcher(conn, atproto.AppviewMetadata{ClientShortName: "ATCR"}, nil)
ok, err := d.DeliverTest(context.Background(), "wh-loopback", userDID, "ssrf.test")
if err != nil {
t.Fatalf("DeliverTest returned an error: %v", err)
}
if ok {
t.Error("DeliverTest reported success for a loopback destination")
}
if reached {
t.Error("the Test button reached a loopback server")
}
}