Resolve real client IP behind trusted proxies (closes #94)
check / check (push) Successful in 2m31s
check / check (push) Successful in 2m31s
RFC1918 ranges are the default trusted proxy set on an omitted key; an explicit list replaces the default; an explicit empty list trusts no one; unparseable values abort startup; forwarded headers honored only from trusted peers. Independent review passed: #127 (comment) model: claude-opus-4-8 (implementation and review); merged by claude-fable-5
This commit was merged in pull request #127.
This commit is contained in:
+61
-19
@@ -44,6 +44,7 @@ const (
|
||||
keyUpstreamConnectionsPerHost = "upstream_connections_per_host"
|
||||
keyCacheMaxBytes = "cache_max_bytes"
|
||||
keyBlockedNetworks = "blocked_networks"
|
||||
keyTrustedProxies = "trusted_proxies"
|
||||
)
|
||||
|
||||
// placeholderSigningKey is the dummy signing_key shipped in
|
||||
@@ -117,6 +118,17 @@ type Config struct {
|
||||
// fetcher's dialer; the built-in ranges always apply.
|
||||
BlockedNetworks []netip.Prefix
|
||||
|
||||
// TrustedProxies are the CIDR ranges of reverse proxies whose
|
||||
// forwarding headers may be believed. Forwarded headers are honored
|
||||
// only when the immediate peer falls inside one of these ranges;
|
||||
// otherwise the peer address is used and the headers are ignored, so
|
||||
// an untrusted client cannot spoof its address. An omitted key
|
||||
// defaults to the RFC 1918 private ranges (see defaultTrustedProxies),
|
||||
// since pixa is deployed behind a proxy on a private network; an
|
||||
// explicitly empty list trusts nothing and always uses the peer
|
||||
// address, and an explicit list replaces the default.
|
||||
TrustedProxies []netip.Prefix
|
||||
|
||||
// CacheMaxBytes is the disk cache size limit in bytes. Zero
|
||||
// disables the disk cache entirely. When cache_max_bytes is
|
||||
// omitted from the configuration, this holds the computed default
|
||||
@@ -185,11 +197,24 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
blockedNetworks, err := getBlockedNetworks(sc)
|
||||
blockedNetworks, err := parseCIDRList(sc, keyBlockedNetworks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trustedProxies, err := parseCIDRList(sc, keyTrustedProxies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// parseCIDRList returns a nil slice only when the key is absent; an
|
||||
// explicitly empty list ([]) comes back non-nil and empty. An omitted
|
||||
// key takes the RFC 1918 default, while an explicit empty list is left
|
||||
// as trust-nothing.
|
||||
if trustedProxies == nil {
|
||||
trustedProxies = defaultTrustedProxies()
|
||||
}
|
||||
|
||||
loader := &strictLoader{sc: sc}
|
||||
|
||||
c := &Config{
|
||||
@@ -207,6 +232,7 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||
CacheMaxBytes: loader.int64Val(keyCacheMaxBytes, 0),
|
||||
BlockedNetworks: blockedNetworks,
|
||||
TrustedProxies: trustedProxies,
|
||||
}
|
||||
|
||||
// The computed default for cache_max_bytes needs a validated
|
||||
@@ -322,7 +348,8 @@ func isKnownConfigKey(key string) bool {
|
||||
switch key {
|
||||
case keyDebug, keyMaintenanceMode, keyPort, keyStateDir, keySentryDSN,
|
||||
keyDBURL, keyMetrics, keySigningKey, keyAllowlistHosts, keyAllowHTTP,
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks, "env":
|
||||
keyUpstreamConnectionsPerHost, keyCacheMaxBytes, keyBlockedNetworks,
|
||||
keyTrustedProxies, "env":
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -817,27 +844,42 @@ func getStringSlice(sc *smartconfig.Config) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getBlockedNetworks parses the blocked_networks value into CIDR prefixes,
|
||||
// or returns nil if the key is omitted. It accepts a YAML list of strings
|
||||
// or a comma-separated string. An explicitly null value, a wrong type, an
|
||||
// empty entry, a non-string entry, or an unparseable CIDR aborts startup
|
||||
// naming the key and the offending value; a default (the built-in
|
||||
// blocklist alone) applies only to an omitted key.
|
||||
func getBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
|
||||
// defaultTrustedProxies returns the trusted_proxies default: the three RFC
|
||||
// 1918 private ranges. pixa is always deployed behind a TLS-terminating
|
||||
// reverse proxy, which in practice sits on a private network, so its
|
||||
// forwarding headers are believed unless the operator says otherwise.
|
||||
// Loopback is deliberately excluded: it is not an RFC 1918 range, and no
|
||||
// deployment reaches pixa over it. A fresh slice is returned on each call so
|
||||
// callers may hold it without aliasing shared state.
|
||||
func defaultTrustedProxies() []netip.Prefix {
|
||||
return []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
}
|
||||
}
|
||||
|
||||
// parseCIDRList parses the value of the named config key into CIDR
|
||||
// prefixes, or returns nil if the key is omitted. It accepts a YAML list
|
||||
// of strings or a comma-separated string. An explicitly null value, a
|
||||
// wrong type, an empty entry, a non-string entry, or an unparseable CIDR
|
||||
// aborts startup naming the key and the offending value; the default
|
||||
// (an empty list) applies only to an omitted key.
|
||||
func parseCIDRList(sc *smartconfig.Config, key string) ([]netip.Prefix, error) {
|
||||
if sc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
raw, ok := sc.Get(keyBlockedNetworks)
|
||||
raw, ok := sc.Get(key)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if raw == nil {
|
||||
return nil, errNullConfigValue(keyBlockedNetworks)
|
||||
return nil, errNullConfigValue(key)
|
||||
}
|
||||
|
||||
entries, err := blockedNetworkEntries(raw)
|
||||
entries, err := cidrListEntries(raw, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -848,7 +890,7 @@ func getBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
|
||||
prefix, err := netip.ParsePrefix(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config key %q: value %q is %w",
|
||||
keyBlockedNetworks, entry, errNotAValidCIDR)
|
||||
key, entry, errNotAValidCIDR)
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, prefix)
|
||||
@@ -857,10 +899,10 @@ func getBlockedNetworks(sc *smartconfig.Config) ([]netip.Prefix, error) {
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// blockedNetworkEntries extracts the raw blocked_networks entries as
|
||||
// cidrListEntries extracts the raw entries of the named CIDR-list key as
|
||||
// trimmed, non-empty strings, from either a YAML list of strings or a
|
||||
// comma-separated string. Any other shape is a configuration error.
|
||||
func blockedNetworkEntries(raw any) ([]string, error) {
|
||||
func cidrListEntries(raw any, key string) ([]string, error) {
|
||||
switch val := raw.(type) {
|
||||
case []any:
|
||||
entries := make([]string, 0, len(val))
|
||||
@@ -869,12 +911,12 @@ func blockedNetworkEntries(raw any) ([]string, error) {
|
||||
str, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("config key %q: list entry %v (%T) is %w",
|
||||
keyBlockedNetworks, item, item, errNotAString)
|
||||
key, item, item, errNotAString)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(str) == "" {
|
||||
return nil, fmt.Errorf("config key %q: %w",
|
||||
keyBlockedNetworks, errEmptyListEntry)
|
||||
key, errEmptyListEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, strings.TrimSpace(str))
|
||||
@@ -888,7 +930,7 @@ func blockedNetworkEntries(raw any) ([]string, error) {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("config key %q: value %q %w",
|
||||
keyBlockedNetworks, val, errEmptyEntry)
|
||||
key, val, errEmptyEntry)
|
||||
}
|
||||
|
||||
entries = append(entries, trimmed)
|
||||
@@ -897,6 +939,6 @@ func blockedNetworkEntries(raw any) ([]string, error) {
|
||||
return entries, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("config key %q: value %v (%T) is %w",
|
||||
keyBlockedNetworks, raw, raw, errNotAStringList)
|
||||
key, raw, raw, errNotAStringList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTrustedProxiesConfig checks the trusted_proxies key wiring: an
|
||||
// explicit CIDR list lands in TrustedProxies in order and replaces the
|
||||
// default, an omitted key falls back to the RFC 1918 private ranges, and an
|
||||
// explicitly empty list trusts no one. The list parser itself is shared with
|
||||
// blocked_networks and is exercised in depth by that key's tests.
|
||||
func TestTrustedProxiesConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("explicit list replaces the default in order", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t,
|
||||
signingKeyLine+`trusted_proxies: ["10.0.0.0/8", "2001:db8::/32"]`+"\n")
|
||||
if err != nil {
|
||||
t.Fatalf("valid trusted_proxies should load: %v", err)
|
||||
}
|
||||
|
||||
got := make([]string, len(c.TrustedProxies))
|
||||
for i, p := range c.TrustedProxies {
|
||||
got[i] = p.String()
|
||||
}
|
||||
|
||||
if joined := strings.Join(got, ","); joined != "10.0.0.0/8,2001:db8::/32" {
|
||||
t.Errorf("TrustedProxies = %v, want the two ranges in order", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omitted key defaults to the RFC 1918 ranges", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine)
|
||||
if err != nil {
|
||||
t.Fatalf("minimal config should load: %v", err)
|
||||
}
|
||||
|
||||
got := make([]string, len(c.TrustedProxies))
|
||||
for i, p := range c.TrustedProxies {
|
||||
got[i] = p.String()
|
||||
}
|
||||
|
||||
want := "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
if joined := strings.Join(got, ","); joined != want {
|
||||
t.Errorf("TrustedProxies = %v, want the RFC 1918 ranges %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicitly empty list trusts no one", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c, err := configFromYAML(t, signingKeyLine+"trusted_proxies: []\n")
|
||||
if err != nil {
|
||||
t.Fatalf("empty trusted_proxies should load: %v", err)
|
||||
}
|
||||
|
||||
if len(c.TrustedProxies) != 0 {
|
||||
t.Errorf("TrustedProxies = %v, want empty", c.TrustedProxies)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTrustedProxiesInvalidAbortsStartup checks that an invalid or null
|
||||
// value aborts startup with an error naming the key and the offending value.
|
||||
func TestTrustedProxiesInvalidAbortsStartup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runAbortCases(t, []abortCase{
|
||||
{
|
||||
name: "invalid cidr",
|
||||
yaml: signingKeyLine + `trusted_proxies: ["999.0.0.0/8"]` + "\n",
|
||||
wantErrSubstrings: []string{keyTrustedProxies, "999.0.0.0/8"},
|
||||
},
|
||||
{
|
||||
name: "null value",
|
||||
yaml: signingKeyLine + "trusted_proxies:\n",
|
||||
wantErrSubstrings: []string{keyTrustedProxies, nullValueText},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user