Files
fleet/pkg/fleethttp/fleethttp.go
T
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

334 lines
10 KiB
Go

// Package fleethttp provides uniform creation and configuration of HTTP
// related types used throughout Fleet.
package fleethttp
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"sync/atomic"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"golang.org/x/oauth2"
)
// NetworkBlockingMode controls how outbound HTTP connections are filtered.
type NetworkBlockingMode int32
const (
// BlockingDisabled performs no filtering. This is the default for tests,
// CLI tools, and any caller that doesn't go through fleet serve.
BlockingDisabled NetworkBlockingMode = iota
// BlockingFull blocks both the always-blocked tier (loopback, IMDS) and
// private networks (RFC 1918, etc.). This is the production default.
BlockingFull
// BlockingPrivateAllowed blocks the always-blocked tier only. Private
// networks are allowed for environments with on-prem integrations
// (e.g. EJBCA, Jira, SCEP servers). Set via
// --server_allow_private_network_integrations.
BlockingPrivateAllowed
// BlockingBypassAll performs no filtering at all. Used in dev mode
// where integrations are tested against localhost.
BlockingBypassAll
)
// networkBlockingMode holds the current blocking mode. Default is
// BlockingDisabled so tests, CLI tools, and non-serve callers are unaffected.
var networkBlockingMode atomic.Int32
// SetNetworkBlockingMode sets the blocking mode. Called by fleet serve at startup.
func SetNetworkBlockingMode(mode NetworkBlockingMode) {
networkBlockingMode.Store(int32(mode))
}
// ErrPrivateNetworkBlocked is returned when a connection to a private network
// address is blocked.
var ErrPrivateNetworkBlocked = errors.New("connections to private network addresses are blocked")
// alwaysBlockedCIDRs are blocked unconditionally, even when
// --allow_private_network_integrations is set. No legitimate integration
// should ever target these addresses.
var alwaysBlockedCIDRs = parseCIDRs([]string{
"127.0.0.0/8", // loopback
"169.254.0.0/16", // link-local (includes cloud IMDS at 169.254.169.254)
"::1/128", // IPv6 loopback
"fe80::/10", // IPv6 link-local
})
// privateNetworkCIDRs are blocked when private network blocking is enabled.
// Customers with on-prem integrations (e.g. EJBCA, Jira, SCEP servers on
// private networks) can disable this with --allow_private_network_integrations.
var privateNetworkCIDRs = parseCIDRs([]string{
"0.0.0.0/8", // "this" network (RFC 1122)
"10.0.0.0/8", // RFC 1918 private
"100.64.0.0/10", // shared address space (RFC 6598)
"172.16.0.0/12", // RFC 1918 private
"192.0.0.0/24", // IETF protocol assignments
"192.168.0.0/16", // RFC 1918 private
"198.18.0.0/15", // benchmarking (RFC 2544)
"198.51.100.0/24", // TEST-NET-2 (documentation)
"203.0.113.0/24", // TEST-NET-3 (documentation)
"224.0.0.0/4", // multicast
"240.0.0.0/4", // reserved
"fc00::/7", // IPv6 unique local
"ff00::/8", // IPv6 multicast
})
// parseCIDRs converts CIDR strings (e.g. "10.0.0.0/8") into net.IPNet objects
// for IP range matching. Panics on malformed input since the lists are hardcoded
// constants -- this runs once at package init, before the server starts.
func parseCIDRs(cidrs []string) []*net.IPNet {
nets := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
panic("fleethttp: bad CIDR " + cidr)
}
nets = append(nets, ipNet)
}
return nets
}
// ipInCIDRs returns true if the given IP falls within any of the provided CIDR ranges.
func ipInCIDRs(ip net.IP, cidrs []*net.IPNet) bool {
for _, cidr := range cidrs {
if cidr.Contains(ip) {
return true
}
}
return false
}
// privateNetworkBlockingDialContext returns a DialContext function that blocks
// connections to private/reserved IP addresses. It resolves DNS first, then
// checks the resolved IP before connecting -- this catches DNS rebinding.
func privateNetworkBlockingDialContext(dialer *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
mode := NetworkBlockingMode(networkBlockingMode.Load())
if mode == BlockingDisabled || mode == BlockingBypassAll {
return dialer.DialContext(ctx, network, addr)
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
// Tier 1: always blocked (loopback, cloud IMDS). Cannot be
// overridden with --server_allow_private_network_integrations.
if ipInCIDRs(ip.IP, alwaysBlockedCIDRs) {
return nil, fmt.Errorf("%w: %s resolves to %s", ErrPrivateNetworkBlocked, host, ip.IP)
}
// Tier 2: private networks. Only blocked in BlockingFull mode.
if mode == BlockingFull && ipInCIDRs(ip.IP, privateNetworkCIDRs) {
return nil, fmt.Errorf("%w: %s resolves to %s", ErrPrivateNetworkBlocked, host, ip.IP)
}
}
// Connect using the already-resolved IP to prevent DNS rebinding
// (a second DNS lookup could return a different, malicious IP).
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
}
}
type clientOpts struct {
timeout time.Duration
tlsConf *tls.Config
noFollow bool
cookieJar http.CookieJar
}
// ClientOpt is the type for the client-specific options.
type ClientOpt func(o *clientOpts)
// WithTimeout sets the timeout to use for the HTTP client.
func WithTimeout(t time.Duration) ClientOpt {
return func(o *clientOpts) {
o.timeout = t
}
}
// WithTLSClientConfig provides the TLS configuration to use for the HTTP
// client's transport.
func WithTLSClientConfig(conf *tls.Config) ClientOpt {
return func(o *clientOpts) {
o.tlsConf = conf.Clone()
}
}
// WithFollowRedir configures the HTTP client to follow redirections or not,
// based on the follow value.
func WithFollowRedir(follow bool) ClientOpt {
return func(o *clientOpts) {
o.noFollow = !follow
}
}
// WithCookieJar configures the HTTP client to use the provided
// cookie jar to manage cookies between requests.
func WithCookieJar(jar http.CookieJar) ClientOpt {
return func(o *clientOpts) {
o.cookieJar = jar
}
}
// NewClient returns an HTTP client configured according to the provided
// options.
func NewClient(opts ...ClientOpt) *http.Client {
var co clientOpts
for _, opt := range opts {
opt(&co)
}
//nolint:gocritic
cli := &http.Client{
Timeout: co.timeout,
}
if co.noFollow {
cli.CheckRedirect = noFollowRedirect
}
// Always create a custom transport (even without TLS config) so that
// every client gets the private network blocking DialContext from
// NewTransport. Without this, nil would fall back to Go's default
// transport which has no IP blocking.
var baseTransport http.RoundTripper
if co.tlsConf != nil {
baseTransport = NewTransport(WithTLSConfig(co.tlsConf))
} else if _, ok := http.DefaultTransport.(*http.Transport); ok {
baseTransport = NewTransport()
} else {
// http.DefaultTransport is not a *http.Transport (e.g. test mock).
// Use it directly to preserve the mock chain.
baseTransport = http.DefaultTransport
}
cli.Transport = otelhttp.NewTransport(baseTransport)
if co.cookieJar != nil {
cli.Jar = co.cookieJar
}
return cli
}
type transportOpts struct {
tlsConf *tls.Config
}
// TransportOpt is the type for transport-specific options.
type TransportOpt func(o *transportOpts)
// WithTLSConfig sets the TLS configuration of the transport.
func WithTLSConfig(conf *tls.Config) TransportOpt {
return func(o *transportOpts) {
o.tlsConf = conf.Clone()
}
}
// NewTransport creates an http transport (a type that implements
// http.RoundTripper) with the provided optional options. The transport is
// derived from Go's http.DefaultTransport and only overrides the specific
// parts it needs to, so that it keeps its sane defaults for the rest (such as
// timeouts and proxy support).
func NewTransport(opts ...TransportOpt) *http.Transport {
var to transportOpts
for _, opt := range opts {
opt(&to)
}
// Start from DefaultTransport to inherit its sane defaults. Guard the type
// assertion in case a test replaces DefaultTransport with a non-*Transport.
dt, ok := http.DefaultTransport.(*http.Transport)
if !ok || dt == nil {
dt = &http.Transport{ForceAttemptHTTP2: true} //nolint:gocritic // we are inside fleethttp itself
}
tr := dt.Clone()
if to.tlsConf != nil {
tr.TLSClientConfig = to.tlsConf
}
tr.DialContext = privateNetworkBlockingDialContext(&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
})
return tr
}
func noFollowRedirect(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
// NewGithubClient returns an HTTP client customized for accessing Github.
//
// - If the NETWORK_TEST_GITHUB_TOKEN variable is empty, then this is equivalent to
// call `NewClient()`.
// - If the NETWORK_TEST_GITHUB_TOKEN variable is set, then the client will use the
// token for authentication (as OAuth2 static token).
func NewGithubClient() *http.Client {
if githubToken := os.Getenv("NETWORK_TEST_GITHUB_TOKEN"); githubToken != "" {
cli := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(
&oauth2.Token{
AccessToken: githubToken,
},
))
cli.Transport = otelhttp.NewTransport(cli.Transport)
return cli
}
return NewClient()
}
// HostnamesMatch is an utility function to parse two strings as
// URLs and find if their hostnames match.
func HostnamesMatch(a, b string) (bool, error) {
ap, err := url.Parse(a)
if err != nil {
return false, fmt.Errorf("parsing URL %s: %w", a, err)
}
bp, err := url.Parse(b)
if err != nil {
return false, fmt.Errorf("parsing URL %s: %w", b, err)
}
return ap.Hostname() == bp.Hostname(), nil
}
type SizeLimitTransport struct {
maxSizeBytes int64
}
var ErrMaxSizeExceeded = errors.New("response body exceeds max size")
func NewSizeLimitTransport(maxSizeBytes int64) *SizeLimitTransport {
return &SizeLimitTransport{
maxSizeBytes: maxSizeBytes,
}
}
func (t *SizeLimitTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := http.DefaultTransport.RoundTrip(req)
if err != nil {
return nil, err
}
if contentLen := resp.ContentLength; contentLen > t.maxSizeBytes {
resp.Body.Close()
return nil, ErrMaxSizeExceeded
}
// if no Content-Length header, limit reading the body
if resp.ContentLength < 0 {
resp.Body = http.MaxBytesReader(nil, resp.Body, t.maxSizeBytes)
}
return resp, nil
}