mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 11:44:16 +00:00
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
359 lines
12 KiB
Go
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")
|
|
}
|
|
}
|