Files
Nico e017eb6176 Allow bypassing network blocking in production via config (#49747)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49751

A customer's egress proxy (an Envoy sidecar bound to loopback) was
getting blocked by Fleet's SSRF network-blocking check, since the check
applies to whatever address the HTTP transport dials, including the
proxy hop itself, not just the ultimate destination. There was no
supported way to disable this in production (the existing full-bypass
mode was dev-only), leaving no path forward for environments where
egress is already constrained by external infrastructure.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.


## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a production server setting to bypass outbound network blocking
for integration requests when external egress controls are already in
place.
* The setting is disabled by default and can be configured through the
server configuration.

* **Documentation**
* Clarified that bypassing network blocking disables SSRF protections
for all outbound integration requests.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-22 13:23:21 -03:00

339 lines
11 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, and
// can also be set in production via --server_bypass_network_blocking as
// an infra-level escape hatch for environments where egress is already
// constrained by external infrastructure (e.g. a proxy or firewall) that
// Fleet's own checks would otherwise conflict with. Disables SSRF
// protection for every outbound integration request, not just the one
// causing the conflict.
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
}