From 607dc28413c0c24439454e973065f67a94d9fd21 Mon Sep 17 00:00:00 2001 From: Abdellatif Anaflous <62770500+hktitof@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:13:10 +0100 Subject: [PATCH] fix(webhook): block unspecified-address destinations in the SSRF guard (#6284) Co-authored-by: hktitof --- internal/webhook/validate.go | 7 +++++-- internal/webhook/webhook_test.go | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/internal/webhook/validate.go b/internal/webhook/validate.go index f4d47c87..fda5aea9 100644 --- a/internal/webhook/validate.go +++ b/internal/webhook/validate.go @@ -14,14 +14,17 @@ import ( // reservedNetworks lists IP ranges blocked by default for outbound webhook requests. // Explicit allowlist entries or the deprecated blanket override may permit them. The -// ranges cover loopback, RFC-1918 private, link-local (including cloud IMDS at -// 169.254.169.254), and their IPv6 equivalents. +// ranges cover "this network" (the kernel treats it as the local host), loopback, +// RFC-1918 private, link-local (including cloud IMDS at 169.254.169.254), and their +// IPv6 equivalents. var reservedNetworks = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), // "this network" (RFC 791), dials the local host netip.MustParsePrefix("127.0.0.0/8"), // IPv4 loopback netip.MustParsePrefix("10.0.0.0/8"), // RFC-1918 class A netip.MustParsePrefix("172.16.0.0/12"), // RFC-1918 class B netip.MustParsePrefix("192.168.0.0/16"), // RFC-1918 class C netip.MustParsePrefix("169.254.0.0/16"), // Link-local / cloud IMDS + netip.MustParsePrefix("::/128"), // IPv6 unspecified address, dials the local host netip.MustParsePrefix("::1/128"), // IPv6 loopback netip.MustParsePrefix("fc00::/7"), // IPv6 unique local netip.MustParsePrefix("fe80::/10"), // IPv6 link-local diff --git a/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go index 22b2bb21..092b279a 100644 --- a/internal/webhook/webhook_test.go +++ b/internal/webhook/webhook_test.go @@ -300,3 +300,36 @@ func TestPostWithoutSecretSetsNoSignatureHeaders(t *testing.T) { require.NoError(t, err) require.False(t, hasSignatureHeaders, "no signature headers should be set when no secret is configured") } + +// TestPostRejectsUnspecifiedAddressDestination pins that a webhook URL pointing at the +// unspecified address is refused at dial time, bcs the kernel treats it as the local host. +func TestPostRejectsUnspecifiedAddressDestination(t *testing.T) { + resetPrivateDestinationPolicy(t) + + var receivedRequest atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + receivedRequest.Store(true) + _, _ = w.Write([]byte(`{"code":0}`)) + })) + defer server.Close() + + _, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://")) + require.NoError(t, err) + + err = Post(&WebhookRequestPayload{ + URL: "http://0.0.0.0:" + port, + ActivityType: "memos.memo.created", + Creator: "users/1", + }) + require.Error(t, err) + require.False(t, receivedRequest.Load(), "the webhook must not reach a local service through the unspecified address") +} + +// TestValidateURLRejectsUnspecifiedAddress ensures webhook URL validation also refuses +// the unspecified address, so such URLs never even reach the dispatch path. +func TestValidateURLRejectsUnspecifiedAddress(t *testing.T) { + resetPrivateDestinationPolicy(t) + + require.Error(t, ValidateURL("http://0.0.0.0:8080/hook")) + require.Error(t, ValidateURL("http://[::]:8080/hook")) +}