hscontrol: gate proxy header trust on trusted_proxies

chi middleware.RealIP was mounted unconditionally on both the
public router and the noise router, so any client could send
X-Real-IP or X-Forwarded-For and have the spoofed value land in
r.RemoteAddr and the access-log remote= field.

Add a top-level trusted_proxies config option (list of CIDRs) and
replace middleware.RealIP with a gated middleware that:

  - honours True-Client-IP / X-Real-IP / X-Forwarded-For only when
    r.RemoteAddr is inside one of the configured prefixes;
  - strips those three headers from every request whose peer is
    not trusted, so downstream handlers cannot read them.

X-Forwarded-For is parsed via realclientip-go's
RightmostTrustedRangeStrategy so a prepended value cannot win in a
proxy chain. trustedProxies() rejects 0.0.0.0/0 and ::/0 at config
load.

Empty trusted_proxies (the default) skips the mount entirely;
r.RemoteAddr is the directly-connecting TCP peer.
This commit is contained in:
Kristoffer Dalby 2026-05-18 09:21:32 +00:00
parent 1f48ebb376
commit c6c29c05e5
6 changed files with 511 additions and 2 deletions

View file

@ -35,6 +35,7 @@ var (
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable")
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
errTrustedProxyZeroRange = errors.New("0.0.0.0/0 and ::/0 are not allowed")
ErrNoPrefixConfigured = errors.New("no IPv4 or IPv6 prefix configured, minimum one prefix is required")
ErrInvalidAllocationStrategy = errors.New("invalid prefix allocation strategy")
)
@ -98,6 +99,7 @@ type Config struct {
MetricsAddr string
GRPCAddr string
GRPCAllowInsecure bool
TrustedProxies []netip.Prefix
Node NodeConfig
PrefixV4 *netip.Prefix
PrefixV6 *netip.Prefix
@ -1049,6 +1051,31 @@ func prefixV6() (*netip.Prefix, bool, error) {
return &prefixV6, !ipSet.ContainsPrefix(prefixV6), nil
}
// trustedProxies rejects 0.0.0.0/0 and ::/0 because they defeat the
// peer-trust gate and almost always indicate misconfiguration.
func trustedProxies() ([]netip.Prefix, error) {
raw := viper.GetStringSlice("trusted_proxies")
if len(raw) == 0 {
return nil, nil
}
out := make([]netip.Prefix, 0, len(raw))
for i, s := range raw {
p, err := netip.ParsePrefix(s)
if err != nil {
return nil, fmt.Errorf("trusted_proxies[%d] %q: %w", i, s, err)
}
if p.Bits() == 0 {
return nil, fmt.Errorf("trusted_proxies[%d] %q: %w", i, s, errTrustedProxyZeroRange)
}
out = append(out, p.Masked())
}
return out, nil
}
// LoadCLIConfig returns the needed configuration for the CLI client
// of Headscale to connect to a Headscale server.
func LoadCLIConfig() (*Config, error) {
@ -1088,6 +1115,11 @@ func LoadServerConfig() (*Config, error) {
return nil, err
}
trusted, err := trustedProxies()
if err != nil {
return nil, err
}
if prefix4 == nil && prefix6 == nil {
return nil, ErrNoPrefixConfigured
}
@ -1178,6 +1210,7 @@ func LoadServerConfig() (*Config, error) {
MetricsAddr: viper.GetString("metrics_listen_addr"),
GRPCAddr: viper.GetString("grpc_listen_addr"),
GRPCAllowInsecure: viper.GetBool("grpc_allow_insecure"),
TrustedProxies: trusted,
DisableUpdateCheck: false,
PrefixV4: prefix4,