Files
fleet/pkg/fleethttp/fleethttp_test.go
7f8e800003 Add private network IP blocking for outbound HTTP requests (#46463)
**Related issue:** N/A (security hardening)

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Summary

Added network-level validation for outbound HTTP requests made by Fleet
integrations (webhooks, SSO, Jira, Zendesk, certificate authorities,
etc.) to prevent requests to unintended destinations. Includes a
configuration option for environments that require connectivity to
private network addresses.

Also fixes a pre-existing nil pointer panic in Jira retry logic and
ensures all HTTP clients use the validated transport.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

Unit and integration tests cover validation logic, boundary conditions,
and multiple configuration modes.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-29 10:41:16 -04:00

309 lines
9.0 KiB
Go

package fleethttp
import (
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"time"
"unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func TestClient(t *testing.T) {
cases := []struct {
name string
opts []ClientOpt
nilRedirect bool
timeout time.Duration
}{
{"default", nil, true, 0},
{"timeout", []ClientOpt{WithTimeout(time.Second)}, true, time.Second},
{"nofollow", []ClientOpt{WithFollowRedir(false)}, false, 0},
{"tlsconfig", []ClientOpt{WithTLSClientConfig(&tls.Config{})}, true, 0},
{"combined", []ClientOpt{
WithTLSClientConfig(&tls.Config{}),
WithTimeout(time.Second),
WithFollowRedir(false),
}, false, time.Second},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cli := NewClient(c.opts...)
require.IsType(t, &otelhttp.Transport{}, cli.Transport, "outer transport should be otelhttp")
// Inspect the inner (base) transport wrapped by otelhttp via unsafe since the rt field is unexported.
rtField := reflect.ValueOf(cli.Transport).Elem().FieldByName("rt")
inner := *(*http.RoundTripper)(unsafe.Pointer(rtField.UnsafeAddr())) //nolint:gosec
// All clients use a custom transport with the private network blocking DialContext.
assert.IsType(t, &http.Transport{}, inner, "inner transport should be a custom *http.Transport") //nolint:gocritic
if c.nilRedirect {
assert.Nil(t, cli.CheckRedirect)
} else {
assert.NotNil(t, cli.CheckRedirect)
}
assert.Equal(t, c.timeout, cli.Timeout)
})
}
}
func TestTransport(t *testing.T) {
defaultTLSConf := http.DefaultTransport.(*http.Transport).TLSClientConfig
cases := []struct {
name string
opts []TransportOpt
defaultTLS bool
}{
{"default", nil, true},
{"tlsconf", []TransportOpt{WithTLSConfig(&tls.Config{})}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
tr := NewTransport(c.opts...)
if c.defaultTLS {
assert.Equal(t, defaultTLSConf, tr.TLSClientConfig)
} else {
assert.NotEqual(t, defaultTLSConf, tr.TLSClientConfig)
}
assert.NotNil(t, tr.Proxy)
assert.NotNil(t, tr.DialContext)
})
}
}
func TestParseCIDRs(t *testing.T) {
t.Run("valid CIDRs", func(t *testing.T) {
result := parseCIDRs([]string{"10.0.0.0/8", "192.168.0.0/16"})
require.Len(t, result, 2)
assert.True(t, result[0].Contains(net.ParseIP("10.0.0.1")))
assert.False(t, result[0].Contains(net.ParseIP("11.0.0.1")))
assert.True(t, result[1].Contains(net.ParseIP("192.168.1.1")))
assert.False(t, result[1].Contains(net.ParseIP("192.169.1.1")))
})
t.Run("empty list", func(t *testing.T) {
result := parseCIDRs([]string{})
assert.Empty(t, result)
})
t.Run("invalid CIDR panics", func(t *testing.T) {
assert.Panics(t, func() {
parseCIDRs([]string{"not-a-cidr"})
})
})
}
func TestIpInCIDRs(t *testing.T) {
cidrs := parseCIDRs([]string{"10.0.0.0/8", "172.16.0.0/12"})
cases := []struct {
ip string
match bool
}{
{"10.0.0.1", true},
{"10.255.255.255", true},
{"172.16.0.1", true},
{"172.31.255.255", true},
{"172.32.0.1", false},
{"192.168.1.1", false},
{"8.8.8.8", false},
}
for _, c := range cases {
t.Run(c.ip, func(t *testing.T) {
assert.Equal(t, c.match, ipInCIDRs(net.ParseIP(c.ip), cidrs))
})
}
}
func TestAlwaysBlockedIPs(t *testing.T) {
// These IPs are always blocked, even with --allow_private_network_integrations.
cases := []struct {
ip string
blocked bool
}{
{"127.0.0.1", true},
{"127.0.0.2", true},
{"169.254.169.254", true}, // AWS IMDS
{"169.254.0.1", true},
{"::1", true}, // IPv6 loopback
{"fe80::1", true}, // IPv6 link-local
{"8.8.8.8", false}, // public
{"10.0.0.1", false}, // RFC 1918 -- not in always-blocked
{"192.168.1.1", false}, // RFC 1918 -- not in always-blocked
}
for _, c := range cases {
t.Run(c.ip, func(t *testing.T) {
ip := net.ParseIP(c.ip)
require.NotNil(t, ip)
assert.Equal(t, c.blocked, ipInCIDRs(ip, alwaysBlockedCIDRs))
})
}
}
func TestPrivateNetworkCIDRs(t *testing.T) {
// These IPs are blocked when private network blocking is enabled.
cases := []struct {
ip string
private bool
}{
{"10.0.0.1", true},
{"10.255.255.255", true},
{"172.16.0.1", true},
{"172.31.255.255", true},
{"192.168.1.1", true},
{"0.0.0.0", true},
{"fc00::1", true}, // IPv6 unique local
{"8.8.8.8", false}, // public
{"1.1.1.1", false}, // public
{"172.32.0.1", false}, // just outside 172.16.0.0/12
}
for _, c := range cases {
t.Run(c.ip, func(t *testing.T) {
ip := net.ParseIP(c.ip)
require.NotNil(t, ip)
assert.Equal(t, c.private, ipInCIDRs(ip, privateNetworkCIDRs))
})
}
}
func setBlockingMode(t *testing.T, mode NetworkBlockingMode) {
t.Helper()
SetNetworkBlockingMode(mode)
t.Cleanup(func() { SetNetworkBlockingMode(BlockingDisabled) })
}
func TestPrivateNetworkBlockingDialContext(t *testing.T) {
// Start a test server on localhost (always-blocked: loopback).
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
t.Run("loopback blocked when blocking enabled", func(t *testing.T) {
setBlockingMode(t, BlockingFull)
client := NewClient(WithTimeout(5 * time.Second))
_, err := client.Get(ts.URL)
require.ErrorIs(t, err, ErrPrivateNetworkBlocked)
assert.Contains(t, err.Error(), "127.0.0.1")
})
t.Run("loopback blocked even with allow_private_network flag", func(t *testing.T) {
// Tier 1 (always-blocked) cannot be overridden by the flag.
setBlockingMode(t, BlockingPrivateAllowed)
client := NewClient(WithTimeout(5 * time.Second))
_, err := client.Get(ts.URL)
require.ErrorIs(t, err, ErrPrivateNetworkBlocked)
})
t.Run("not blocked when blocking is not enabled", func(t *testing.T) {
// Default state: blocking not enabled (tests, CLI).
client := NewClient(WithTimeout(5 * time.Second))
resp, err := client.Get(ts.URL)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("public IP allowed when blocking enabled", func(t *testing.T) {
setBlockingMode(t, BlockingFull)
client := NewClient(WithTimeout(5 * time.Second))
// google.com is public -- should not be blocked (may fail for other
// reasons in CI, so we only check it's not ErrPrivateNetworkBlocked).
_, err := client.Get("https://google.com")
if err != nil {
assert.NotErrorIs(t, err, ErrPrivateNetworkBlocked)
}
})
t.Run("error message includes hostname and IP", func(t *testing.T) {
setBlockingMode(t, BlockingFull)
client := NewClient(WithTimeout(5 * time.Second))
_, err := client.Get(ts.URL)
require.Error(t, err)
assert.Contains(t, err.Error(), "127.0.0.1 resolves to 127.0.0.1")
})
t.Run("invalid address returns error", func(t *testing.T) {
setBlockingMode(t, BlockingFull)
dialFn := privateNetworkBlockingDialContext(&net.Dialer{Timeout: time.Second})
_, err := dialFn(t.Context(), "tcp", "no-port")
require.Error(t, err)
// Should fail on SplitHostPort, not on blocking.
assert.NotErrorIs(t, err, ErrPrivateNetworkBlocked)
})
t.Run("unresolvable host returns error", func(t *testing.T) {
setBlockingMode(t, BlockingFull)
dialFn := privateNetworkBlockingDialContext(&net.Dialer{Timeout: time.Second})
_, err := dialFn(t.Context(), "tcp", "this-host-does-not-exist.invalid:443")
require.Error(t, err)
assert.NotErrorIs(t, err, ErrPrivateNetworkBlocked)
})
t.Run("connects to resolved IP not hostname", func(t *testing.T) {
setBlockingMode(t, BlockingFull)
dialFn := privateNetworkBlockingDialContext(&net.Dialer{Timeout: time.Second})
_, err := dialFn(t.Context(), "tcp", "localhost:9999")
require.ErrorIs(t, err, ErrPrivateNetworkBlocked)
assert.Contains(t, err.Error(), "localhost resolves to")
})
}
func TestHostnamesMatch(t *testing.T) {
tests := []struct {
name string
inputA string
inputB string
expectedMatch bool
expectError bool
}{
{
name: "ValidHostnamesMatch",
inputA: "https://www.example.com/path",
inputB: "http://www.example.com:80",
expectedMatch: true,
expectError: false,
},
{
name: "ValidHostnamesDoNotMatch",
inputA: "https://www.example.com",
inputB: "https://sub.example.com",
expectedMatch: false,
expectError: false,
},
{
name: "InvalidURLA",
inputA: "ht tp://foo.com",
inputB: "https://www.example.com",
expectedMatch: false,
expectError: true,
},
{
name: "InvalidURLB",
inputA: "https://www.example.com",
inputB: "ht tp://foo.com",
expectedMatch: false,
expectError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matched, err := HostnamesMatch(test.inputA, test.inputB)
if test.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, test.expectedMatch, matched)
}
})
}
}