Gate forwarded-header trust behind trusted-proxy config (closes #88)
All checks were successful
check / check (push) Successful in 2m46s
All checks were successful
check / check (push) Successful in 2m46s
Every rate limiter keyed on httprate.KeyByRealIP, which believes True-Client-IP, X-Real-IP and the first X-Forwarded-For entry from any peer. A client could therefore mint a fresh bucket per request by rotating a spoofed header, or drain another client's bucket by claiming its address, which left the receiver, login and password change limits with no value against a deliberate attacker. The receiver, login and password change limiters now share one key function: the connection's own address, unless the direct peer is inside a network listed in the new TRUSTED_PROXIES CIDR list, in which case the forwarded client address is used. The list is empty by default, so nothing is trusted until an operator names their proxy; a set-but-unparseable value aborts startup, matching the handling of the other parsed variables. X-Forwarded-For is the only forwarded header read, from any peer. Reverse proxies append to it but pass other client headers through verbatim, so believing a single-valued X-Real-IP or True-Client-IP would hand a client behind the trusted proxy a fresh bucket per request - the same bypass, inside the deployment TRUSTED_PROXIES exists to serve. Within a trusted request the chain is walked right to left, since the rightmost entry is the one the nearest proxy appended, and the first hop that is not itself a trusted proxy is taken as the client. A hop that is not a bare address - ip:port, a bracketed IPv6 literal, the token unknown - ends the walk and the peer address is used, rather than continuing left into entries the client controls. Trusted-proxy prefixes written in IPv4-mapped form are unmapped at parse time, since peer addresses are unmapped before matching and such a prefix would otherwise silently never match. Also folds in two cleanups from the same review: the 429 responder shared by all three limiters is extracted, and the RECEIVER_RATE_LIMIT error-path tests now assert that the failure names the variable and wraps ErrNonPositiveValue rather than only that some error occurred.
This commit is contained in:
@@ -5,8 +5,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -45,6 +47,11 @@ const (
|
||||
// maxPort is the highest valid TCP port number. The lower
|
||||
// bound (at least 1) is enforced by envPositiveInt.
|
||||
maxPort = 65535
|
||||
|
||||
// mappedV4Offset is the number of leading bits an IPv4-mapped
|
||||
// IPv6 prefix spends on the ::ffff:0:0/96 wrapper, so a /104
|
||||
// covers the same addresses as an IPv4 /8.
|
||||
mappedV4Offset = 96
|
||||
)
|
||||
|
||||
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
||||
@@ -59,6 +66,11 @@ var ErrNonPositiveValue = errors.New("value must be positive")
|
||||
// TCP port number is set above the valid port range.
|
||||
var ErrInvalidPort = errors.New("invalid port")
|
||||
|
||||
// ErrInvalidCIDR is returned when an environment variable holding a
|
||||
// list of CIDR blocks contains an entry that is neither a CIDR block
|
||||
// nor a bare IP address.
|
||||
var ErrInvalidCIDR = errors.New("invalid CIDR")
|
||||
|
||||
//nolint:revive // ConfigParams is a standard fx naming convention.
|
||||
type ConfigParams struct {
|
||||
fx.In
|
||||
@@ -90,6 +102,17 @@ type Config struct {
|
||||
// client IP may send to a single webhook receiver entrypoint.
|
||||
ReceiverRateLimit int
|
||||
|
||||
// TrustedProxies is the set of networks whose members are
|
||||
// allowed to speak for the client with X-Forwarded-For, the
|
||||
// only forwarded header read. It is empty unless
|
||||
// TRUSTED_PROXIES is set, and empty means no peer is
|
||||
// trusted: forwarded headers are then ignored entirely and
|
||||
// clients are identified by the connection's own address.
|
||||
// Members can choose their own rate-limit key, so this must
|
||||
// name proxy hosts only, never a block that also covers
|
||||
// clients.
|
||||
TrustedProxies []netip.Prefix
|
||||
|
||||
params *ConfigParams
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -212,6 +235,71 @@ func envDuration(
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// parseCIDR parses one trusted-proxy list entry, which may be a
|
||||
// CIDR block ("10.0.0.0/8") or a bare address ("10.0.0.1", treated
|
||||
// as a single-host block).
|
||||
//
|
||||
// Both forms are unmapped, because peer addresses are unmapped
|
||||
// before they are matched against the list: an IPv4-mapped prefix
|
||||
// left in that form would silently never match.
|
||||
func parseCIDR(entry string) (netip.Prefix, error) {
|
||||
if strings.Contains(entry, "/") {
|
||||
prefix, err := netip.ParsePrefix(entry)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, err //nolint:wrapcheck // wrapped by caller
|
||||
}
|
||||
|
||||
if addr := prefix.Addr(); addr.Is4In6() &&
|
||||
prefix.Bits() >= mappedV4Offset {
|
||||
prefix = netip.PrefixFrom(
|
||||
addr.Unmap(), prefix.Bits()-mappedV4Offset,
|
||||
)
|
||||
}
|
||||
|
||||
return prefix.Masked(), nil
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(entry)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, err //nolint:wrapcheck // wrapped by caller
|
||||
}
|
||||
|
||||
return netip.PrefixFrom(addr.Unmap(), addr.Unmap().BitLen()), nil
|
||||
}
|
||||
|
||||
// envPrefixList returns the value of the named environment variable
|
||||
// parsed as a comma-separated list of CIDR blocks (bare addresses
|
||||
// allowed). An unset, empty, or blank value yields an empty list. A
|
||||
// set value containing an unparseable entry is a hard error naming
|
||||
// the key and the bad entry, so startup fails loudly rather than
|
||||
// silently running with a list the operator did not intend.
|
||||
func envPrefixList(key string) ([]netip.Prefix, error) {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var prefixes []netip.Prefix
|
||||
|
||||
for entry := range strings.SplitSeq(v, ",") {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
prefix, err := parseCIDR(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %s: %q: %w", ErrInvalidCIDR, key, entry, err,
|
||||
)
|
||||
}
|
||||
|
||||
prefixes = append(prefixes, prefix)
|
||||
}
|
||||
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to
|
||||
// dev, and rejects unrecognised values.
|
||||
func resolveEnvironment() (string, error) {
|
||||
@@ -282,6 +370,11 @@ func loadFromEnv() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Config{
|
||||
DataDir: envString("DATA_DIR"),
|
||||
Debug: debug,
|
||||
@@ -294,6 +387,7 @@ func loadFromEnv() (*Config, error) {
|
||||
RetentionSweepInterval: retentionSweepInterval,
|
||||
SessionIdleTimeout: sessionIdleTimeout,
|
||||
ReceiverRateLimit: receiverRateLimit,
|
||||
TrustedProxies: trustedProxies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -335,6 +429,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
"dataDir", s.DataDir,
|
||||
"retentionSweepInterval", s.RetentionSweepInterval.String(),
|
||||
"receiverRateLimit", s.ReceiverRateLimit,
|
||||
"trustedProxies", len(s.TrustedProxies),
|
||||
"hasSentryDSN", s.SentryDSN != "",
|
||||
"hasMetricsAuth",
|
||||
s.MetricsUsername != "" && s.MetricsPassword != "",
|
||||
|
||||
@@ -20,6 +20,10 @@ const (
|
||||
caseUnsetUsesDefault = "unset uses default"
|
||||
caseValidValueParsed = "valid value is parsed"
|
||||
caseUnparseableFails = "unparseable value fails startup"
|
||||
|
||||
// cidrPrivateV4 is the sample trusted-proxy block the
|
||||
// TRUSTED_PROXIES cases are built from.
|
||||
cidrPrivateV4 = "10.0.0.0/8"
|
||||
)
|
||||
|
||||
func TestEnvironmentConfig(t *testing.T) {
|
||||
@@ -179,9 +183,10 @@ func TestRetentionSweepInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// expectStartupError asserts that fx refuses to build the app,
|
||||
// which is what a set-but-invalid environment value must cause.
|
||||
func expectStartupError(t *testing.T) {
|
||||
// startupError builds the app config.New belongs to and returns
|
||||
// the error fx reports, which is non-nil whenever an environment
|
||||
// value is set but invalid.
|
||||
func startupError(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
var cfg *config.Config
|
||||
@@ -196,7 +201,33 @@ func expectStartupError(t *testing.T) {
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
|
||||
assert.Error(t, app.Err())
|
||||
return app.Err()
|
||||
}
|
||||
|
||||
// expectStartupError asserts that fx refuses to build the app,
|
||||
// which is what a set-but-invalid environment value must cause.
|
||||
func expectStartupError(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
assert.Error(t, startupError(t))
|
||||
}
|
||||
|
||||
// expectStartupErrorFor asserts that startup fails, that the error
|
||||
// names the offending variable so an operator can find it, and,
|
||||
// when sentinel is non-nil, that it wraps that sentinel.
|
||||
func expectStartupErrorFor(
|
||||
t *testing.T,
|
||||
key string,
|
||||
sentinel error,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
err := startupError(t)
|
||||
require.ErrorContains(t, err, key)
|
||||
|
||||
if sentinel != nil {
|
||||
require.ErrorIs(t, err, sentinel)
|
||||
}
|
||||
}
|
||||
|
||||
func testRetentionSweepIntervalSuccess(
|
||||
@@ -351,7 +382,11 @@ func TestReceiverRateLimit(t *testing.T) {
|
||||
set bool
|
||||
value string
|
||||
expectError bool
|
||||
expected int
|
||||
// sentinel, when set, must be wrapped by the startup
|
||||
// error; every error case must additionally name the
|
||||
// variable in its message.
|
||||
sentinel error
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: caseUnsetUsesDefault,
|
||||
@@ -375,12 +410,14 @@ func TestReceiverRateLimit(t *testing.T) {
|
||||
set: true,
|
||||
value: "0",
|
||||
expectError: true,
|
||||
sentinel: config.ErrNonPositiveValue,
|
||||
},
|
||||
{
|
||||
name: "negative fails startup",
|
||||
set: true,
|
||||
value: "-5",
|
||||
expectError: true,
|
||||
sentinel: config.ErrNonPositiveValue,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -399,7 +436,9 @@ func TestReceiverRateLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
expectStartupError(t)
|
||||
expectStartupErrorFor(
|
||||
t, "RECEIVER_RATE_LIMIT", tt.sentinel,
|
||||
)
|
||||
} else {
|
||||
testReceiverRateLimitSuccess(t, tt.expected)
|
||||
}
|
||||
@@ -432,3 +471,116 @@ func testReceiverRateLimitSuccess(
|
||||
|
||||
assert.Equal(t, expected, cfg.ReceiverRateLimit)
|
||||
}
|
||||
|
||||
func TestTrustedProxies(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
set bool
|
||||
value string
|
||||
expectError bool
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
// The default must be "trust nobody": an empty list
|
||||
// means forwarded headers are ignored, never that
|
||||
// every peer may speak for the client.
|
||||
name: caseUnsetUsesDefault,
|
||||
set: false,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "blank value trusts nothing",
|
||||
set: true,
|
||||
value: " ",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: caseValidValueParsed,
|
||||
set: true,
|
||||
value: cidrPrivateV4 + ", 192.168.1.7 ,2001:db8::/32",
|
||||
expected: []string{
|
||||
cidrPrivateV4, "192.168.1.7/32", "2001:db8::/32",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "host bits are masked off",
|
||||
set: true,
|
||||
value: "10.1.2.3/8",
|
||||
expected: []string{cidrPrivateV4},
|
||||
},
|
||||
{
|
||||
// Peer addresses are unmapped before they are
|
||||
// matched, so an IPv4-mapped prefix kept in that
|
||||
// form could never match anything.
|
||||
name: "IPv4-mapped prefix is unmapped",
|
||||
set: true,
|
||||
value: "::ffff:10.0.0.0/104",
|
||||
expected: []string{cidrPrivateV4},
|
||||
},
|
||||
{
|
||||
name: caseUnparseableFails,
|
||||
set: true,
|
||||
value: cidrPrivateV4 + ",not-an-address",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "out-of-range prefix length fails startup",
|
||||
set: true,
|
||||
value: "10.0.0.0/33",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
if tt.set {
|
||||
t.Setenv("TRUSTED_PROXIES", tt.value)
|
||||
} else {
|
||||
require.NoError(t, os.Unsetenv("TRUSTED_PROXIES"))
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
expectStartupErrorFor(
|
||||
t, "TRUSTED_PROXIES", config.ErrInvalidCIDR,
|
||||
)
|
||||
} else {
|
||||
testTrustedProxiesSuccess(t, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testTrustedProxiesSuccess(
|
||||
t *testing.T,
|
||||
expected []string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var cfg *config.Config
|
||||
|
||||
app := fxtest.New(
|
||||
t,
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
config.New,
|
||||
),
|
||||
fx.Populate(&cfg),
|
||||
)
|
||||
require.NoError(t, app.Err())
|
||||
|
||||
app.RequireStart()
|
||||
|
||||
defer app.RequireStop()
|
||||
|
||||
got := make([]string, 0, len(cfg.TrustedProxies))
|
||||
for _, prefix := range cfg.TrustedProxies {
|
||||
got = append(got, prefix.String())
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, got)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user