From 0a7ed7ba91e7a8f41617d809a100222d98202965 Mon Sep 17 00:00:00 2001 From: Thomas Hatzopoulos Date: Tue, 10 Nov 2026 09:56:41 -0600 Subject: [PATCH 1/2] Webhook Dial Guard --- webhook/notifier.go | 11 ++++++----- webhook/resource_url_notifier.go | 18 ++++++++++++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/webhook/notifier.go b/webhook/notifier.go index f8cf43558..88d452044 100644 --- a/webhook/notifier.go +++ b/webhook/notifier.go @@ -181,11 +181,12 @@ func (n *DefaultNotifier) SetFilter(params FilterParams) { // --------------------------------- type HTTPClientParams struct { - RetryWaitMin time.Duration - RetryWaitMax time.Duration - MaxRetries int - ClientTimeout time.Duration - ForceIPv4 bool + RetryWaitMin time.Duration + RetryWaitMax time.Duration + MaxRetries int + ClientTimeout time.Duration + ForceIPv4 bool + BlockPrivateAddresses bool // receivers pin WAF rules to this, so it must stay stable across releases // defaults to DefaultUserAgent UserAgent string diff --git a/webhook/resource_url_notifier.go b/webhook/resource_url_notifier.go index 72a48ca29..dd67b06d8 100644 --- a/webhook/resource_url_notifier.go +++ b/webhook/resource_url_notifier.go @@ -163,9 +163,23 @@ func newHTTPClient(params HTTPClientParams) *retryablehttp.Client { if !ok || tr == nil { tr = http.DefaultTransport.(*http.Transport).Clone() } - if params.ForceIPv4 { + if params.ForceIPv4 || params.BlockPrivateAddresses { + dialer := &net.Dialer{} + if params.BlockPrivateAddresses { + dialer.Control = GuardWebhookDial + } tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "tcp4", addr) + if params.ForceIPv4 { + network = "tcp4" + } + return dialer.DialContext(ctx, network, addr) + } + } + if params.BlockPrivateAddresses { + tr.Proxy = nil + // A redirect is reported as the receiver's answer, not followed. + rhc.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse } } tr.MaxResponseHeaderBytes = maxResponseHeaderBytes From 80949eac69087f8bf5fff3e45ff68cdca07eb81f Mon Sep 17 00:00:00 2001 From: Thomas Hatzopoulos Date: Tue, 10 Nov 2026 09:58:51 -0600 Subject: [PATCH 2/2] Webhook Dial Guard --- webhook/dial_guard.go | 76 +++++++++++++++++++++++++++ webhook/dial_guard_test.go | 105 +++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 webhook/dial_guard.go create mode 100644 webhook/dial_guard_test.go diff --git a/webhook/dial_guard.go b/webhook/dial_guard.go new file mode 100644 index 000000000..1c8c70010 --- /dev/null +++ b/webhook/dial_guard.go @@ -0,0 +1,76 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "errors" + "net/netip" + "strings" + "syscall" +) + +// ErrWebhookTargetBlocked rejects a webhook URL that points at an address this +// server must not reach on a caller's behalf. +var ErrWebhookTargetBlocked = errors.New("webhook url must resolve to a public address") + +// blockedWebhookPrefixes are reserved ranges netip's predicates do not cover. +var blockedWebhookPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), // "this network"; 0.0.0.0 reaches localhost + netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT + netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments + netip.MustParsePrefix("198.18.0.0/15"), // benchmarking + netip.MustParsePrefix("240.0.0.0/4"), // reserved +} + +// checkWebhookAddr rejects loopback, private, link-local (including cloud +// metadata at 169.254.169.254), multicast, unspecified and other reserved +// addresses, in IPv4, IPv6 and IPv4-mapped IPv6 form. +func checkWebhookAddr(addr netip.Addr) error { + addr = addr.Unmap() + if !addr.IsValid() || !addr.IsGlobalUnicast() || addr.IsPrivate() { + return ErrWebhookTargetBlocked + } + for _, p := range blockedWebhookPrefixes { + if p.Contains(addr) { + return ErrWebhookTargetBlocked + } + } + return nil +} + +// CheckWebhookHost rejects a URL host that is localhost or a blocked IP +// literal, so the mistake surfaces when the webhook is saved. Hostnames are +// only resolved, and checked, when a delivery dials them. +func CheckWebhookHost(host string) error { + host = strings.TrimSuffix(strings.ToLower(host), ".") + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return ErrWebhookTargetBlocked + } + if addr, err := netip.ParseAddr(host); err == nil { + return checkWebhookAddr(addr) + } + return nil +} + +// GuardWebhookDial runs after DNS resolution, on the address actually +// dialed, so a hostname resolving (or rebinding) to a blocked address is +// refused before a connection opens. +func GuardWebhookDial(_, address string, _ syscall.RawConn) error { + ap, err := netip.ParseAddrPort(address) + if err != nil { + return ErrWebhookTargetBlocked + } + return checkWebhookAddr(ap.Addr()) +} diff --git a/webhook/dial_guard_test.go b/webhook/dial_guard_test.go new file mode 100644 index 000000000..8979090ca --- /dev/null +++ b/webhook/dial_guard_test.go @@ -0,0 +1,105 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webhook + +import ( + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/hashicorp/go-retryablehttp" + "github.com/stretchr/testify/require" +) + +func TestCheckWebhookAddr(t *testing.T) { + for _, addr := range []string{"93.184.215.14", "8.8.8.8", "2606:4700::1111", "::ffff:8.8.8.8"} { + require.NoError(t, checkWebhookAddr(netip.MustParseAddr(addr)), addr) + } + for _, addr := range []string{ + "127.0.0.1", "127.1.2.3", "::1", // loopback + "10.1.2.3", "172.16.0.1", "192.168.1.1", "fd00::1", // private + "169.254.169.254", "fe80::1", // link-local, cloud metadata + "0.0.0.0", "0.1.2.3", "::", // unspecified, "this network" + "224.0.0.1", "ff02::1", "255.255.255.255", // multicast, broadcast + "100.64.0.1", "198.18.0.1", "240.0.0.1", // CGNAT, benchmarking, reserved + "::ffff:127.0.0.1", "::ffff:10.0.0.1", "::ffff:169.254.169.254", // IPv4-mapped + } { + require.ErrorIs(t, checkWebhookAddr(netip.MustParseAddr(addr)), ErrWebhookTargetBlocked, addr) + } +} + +func TestCheckWebhookHost(t *testing.T) { + for _, host := range []string{"example.com", "93.184.215.14", "2606:4700::1111"} { + require.NoError(t, CheckWebhookHost(host), host) + } + for _, host := range []string{ + "localhost", "LOCALHOST.", "api.localhost", + "127.0.0.1", "10.0.0.1", "169.254.169.254", + "::1", "::ffff:10.0.0.1", "0.0.0.0", + } { + require.ErrorIs(t, CheckWebhookHost(host), ErrWebhookTargetBlocked, host) + } +} + +func TestBlockPrivateAddresses(t *testing.T) { + var hits atomic.Int32 + receiver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + })) + t.Cleanup(receiver.Close) + + u, err := url.Parse(receiver.URL) + require.NoError(t, err) + byIP := receiver.URL + byName := "http://localhost:" + u.Port() + + post := func(t *testing.T, params HTTPClientParams, target string) error { + params.RetryWaitMin = time.Millisecond + params.RetryWaitMax = time.Millisecond + req, err := retryablehttp.NewRequest("POST", target, nil) + require.NoError(t, err) + res, err := newHTTPClient(params).Do(req) + if err != nil { + return err + } + _ = res.Body.Close() + return nil + } + + t.Run("delivers to private addresses when off", func(t *testing.T) { + hits.Store(0) + require.NoError(t, post(t, HTTPClientParams{}, byIP)) + require.EqualValues(t, 1, hits.Load()) + }) + + for name, params := range map[string]HTTPClientParams{ + "refuses private addresses": {BlockPrivateAddresses: true}, + "refuses private addresses with ForceIPv4": {BlockPrivateAddresses: true, ForceIPv4: true}, + } { + t.Run(name, func(t *testing.T) { + hits.Store(0) + // The dial check covers stored URLs saved before the URL check, and + // hostnames that resolve to a blocked address, like localhost here. + for _, target := range []string{byIP, byName} { + require.ErrorIs(t, post(t, params, target), ErrWebhookTargetBlocked, target) + } + require.Zero(t, hits.Load()) + }) + } +}