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:
parent
1f48ebb376
commit
c6c29c05e5
6 changed files with 511 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package types
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
|
@ -510,3 +511,93 @@ func TestConfigJSONOmitsSecrets(t *testing.T) {
|
|||
"marshalled Config must not contain secret %q", secret)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:goconst // repeated CIDR strings are test fixtures, not refactor candidates
|
||||
func TestTrustedProxies(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
want []netip.Prefix
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unset",
|
||||
input: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: []string{},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single-v4",
|
||||
input: []string{"10.0.0.0/16"},
|
||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/16")},
|
||||
},
|
||||
{
|
||||
name: "single-v6",
|
||||
input: []string{"fd00::/8"},
|
||||
want: []netip.Prefix{netip.MustParsePrefix("fd00::/8")},
|
||||
},
|
||||
{
|
||||
name: "mixed-v4-v6",
|
||||
input: []string{"127.0.0.1/32", "::1/128", "10.0.0.0/16"},
|
||||
want: []netip.Prefix{
|
||||
netip.MustParsePrefix("127.0.0.1/32"),
|
||||
netip.MustParsePrefix("::1/128"),
|
||||
netip.MustParsePrefix("10.0.0.0/16"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-canonical-masked",
|
||||
input: []string{"10.0.0.5/16"},
|
||||
want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/16")},
|
||||
},
|
||||
{
|
||||
name: "bare-ip-rejected",
|
||||
input: []string{"10.0.0.1"},
|
||||
wantErr: `trusted_proxies[0] "10.0.0.1"`,
|
||||
},
|
||||
{
|
||||
name: "garbage-reports-index",
|
||||
input: []string{"10.0.0.0/16", "not-an-ip"},
|
||||
wantErr: `trusted_proxies[1] "not-an-ip"`,
|
||||
},
|
||||
{
|
||||
name: "ipv4-zero-rejected",
|
||||
input: []string{"0.0.0.0/0"},
|
||||
wantErr: "0.0.0.0/0 and ::/0 are not allowed",
|
||||
},
|
||||
{
|
||||
name: "ipv6-zero-rejected",
|
||||
input: []string{"::/0"},
|
||||
wantErr: "0.0.0.0/0 and ::/0 are not allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
if tt.input != nil {
|
||||
viper.Set("trusted_proxies", tt.input)
|
||||
}
|
||||
|
||||
got, err := trustedProxies()
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
if diff := cmp.Diff(tt.want, got, cmpopts.EquateComparable(netip.Prefix{})); diff != "" {
|
||||
t.Errorf("trustedProxies() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue