Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions webhook/dial_guard.go
Original file line number Diff line number Diff line change
@@ -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())
}
105 changes: 105 additions & 0 deletions webhook/dial_guard_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
}
11 changes: 6 additions & 5 deletions webhook/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions webhook/resource_url_notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading