feat(canary): add configurable trusted-proxy CIDRs for X-Forwarded-For
RealIP only reads XFF when the immediate peer sits in TRUSTED_PROXY_CIDRS, so an untrusted client can no longer spoof its source IP while a real client behind a known reverse proxy still resolves correctly. Parses the comma-separated list via a knadh/koanf ProviderWithValue callback (blank or whitespace-only input leaves the built-in default intact), wires the var through both compose files and .env.example, and adds MYSQL_FAKE_* and TURNSTILE_SECRET env aliases. Covered by config_test.go.
This commit is contained in:
parent
b705f1ddd1
commit
97da7fb3a0
|
|
@ -23,6 +23,16 @@ PUBLIC_BASE_URL=https://canary.your.domain
|
|||
VITE_APP_TITLE="Canary Token Generator"
|
||||
VITE_API_URL=/api
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reverse proxy / client IP (load-bearing for detection)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comma-separated CIDRs whose X-Forwarded-For header is trusted. RealIP only
|
||||
# reads XFF when the immediate peer is in this list. Too narrow and every event
|
||||
# logs the proxy's IP (GeoIP + dedup break); too wide and a client can spoof its
|
||||
# source IP. Set it to the CIDR your reverse proxy connects from.
|
||||
# Default: 127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloudflare Turnstile (free anti-bot)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package config
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -159,7 +160,7 @@ func Load(configPath string) (*Config, error) {
|
|||
}
|
||||
|
||||
if err := k.Load(
|
||||
env.Provider("", ".", envKeyReplacer),
|
||||
env.ProviderWithValue("", ".", envCallback),
|
||||
nil,
|
||||
); err != nil {
|
||||
loadErr = fmt.Errorf("load env vars: %w", err)
|
||||
|
|
@ -315,10 +316,14 @@ var envKeyMap = map[string]string{
|
|||
"CANARY_BASE_URL": "canary.base_url",
|
||||
"PUBLIC_BASE_URL": "canary.base_url",
|
||||
"CANARY_MANAGE_URL": "canary.manage_url",
|
||||
"TRUSTED_PROXY_CIDRS": "server.trusted_proxy_cidrs",
|
||||
"TURNSTILE_SECRET_KEY": "turnstile.secret_key",
|
||||
"TURNSTILE_SECRET": "turnstile.secret_key",
|
||||
"TURNSTILE_SITE_KEY": "turnstile.site_key",
|
||||
"MYSQL_ENABLED": "mysql.enabled",
|
||||
"MYSQL_FAKE_ENABLED": "mysql.enabled",
|
||||
"MYSQL_ADDR": "mysql.addr",
|
||||
"MYSQL_FAKE_ADDR": "mysql.addr",
|
||||
"MYSQL_PUBLIC_HOST": "mysql.public_host",
|
||||
"MYSQL_PUBLIC_PORT": "mysql.public_port",
|
||||
"RATE_LIMIT_CREATE_MIN_RATE": "rate_limit.create_min_rate",
|
||||
|
|
@ -339,6 +344,10 @@ var envKeyMap = map[string]string{
|
|||
"GEOLITE_PATH": "geoip.path",
|
||||
}
|
||||
|
||||
var envSliceKeys = map[string]struct{}{
|
||||
"server.trusted_proxy_cidrs": {},
|
||||
}
|
||||
|
||||
func envKeyReplacer(s string) string {
|
||||
if mapped, ok := envKeyMap[s]; ok {
|
||||
return mapped
|
||||
|
|
@ -346,6 +355,27 @@ func envKeyReplacer(s string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func envCallback(key, value string) (string, any) {
|
||||
mapped := envKeyReplacer(key)
|
||||
if mapped == "" {
|
||||
return "", nil
|
||||
}
|
||||
if _, isSlice := envSliceKeys[mapped]; isSlice {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "", nil // blank/empty: leave the configured default intact
|
||||
}
|
||||
return mapped, out
|
||||
}
|
||||
return mapped, value
|
||||
}
|
||||
|
||||
func validate(c *Config) error {
|
||||
if c.Database.URL == "" {
|
||||
return fmt.Errorf("DATABASE_URL is required")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
// ©AngelaMos | 2026
|
||||
// config_test.go
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvCallbackScalarAndAliases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
wantKey string
|
||||
wantVal any
|
||||
}{
|
||||
{"mysql fake enabled alias", "MYSQL_FAKE_ENABLED", "true", "mysql.enabled", "true"},
|
||||
{"mysql canonical enabled", "MYSQL_ENABLED", "true", "mysql.enabled", "true"},
|
||||
{"mysql fake addr alias", "MYSQL_FAKE_ADDR", "0.0.0.0:33306", "mysql.addr", "0.0.0.0:33306"},
|
||||
{"turnstile secret alias", "TURNSTILE_SECRET", "sk", "turnstile.secret_key", "sk"},
|
||||
{"unmapped key dropped", "SOME_RANDOM_VAR", "x", "", nil},
|
||||
{"blank slice dropped keeps default", "TRUSTED_PROXY_CIDRS", "", "", nil},
|
||||
{"whitespace-only slice dropped", "TRUSTED_PROXY_CIDRS", " , ,", "", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotKey, gotVal := envCallback(c.key, c.value)
|
||||
if gotKey != c.wantKey {
|
||||
t.Fatalf("key: got %q, want %q", gotKey, c.wantKey)
|
||||
}
|
||||
if !reflect.DeepEqual(gotVal, c.wantVal) {
|
||||
t.Fatalf("value: got %#v, want %#v", gotVal, c.wantVal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvCallbackSliceSplitting(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
want []string
|
||||
}{
|
||||
{"single", "10.0.0.0/8", []string{"10.0.0.0/8"}},
|
||||
{"multiple", "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16", []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}},
|
||||
{"spaces trimmed", " 10.0.0.0/8 , 172.16.0.0/12 ", []string{"10.0.0.0/8", "172.16.0.0/12"}},
|
||||
{"empty entries dropped", "10.0.0.0/8,,192.168.0.0/16,", []string{"10.0.0.0/8", "192.168.0.0/16"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotKey, gotVal := envCallback("TRUSTED_PROXY_CIDRS", c.value)
|
||||
if gotKey != "server.trusted_proxy_cidrs" {
|
||||
t.Fatalf("key: got %q, want %q", gotKey, "server.trusted_proxy_cidrs")
|
||||
}
|
||||
got, ok := gotVal.([]string)
|
||||
if !ok {
|
||||
t.Fatalf("value type: got %T, want []string", gotVal)
|
||||
}
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("value: got %#v, want %#v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ services:
|
|||
- GEOLITE_PATH=/data/GeoLite2-City.mmdb
|
||||
- WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-}
|
||||
- MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false}
|
||||
- TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- LOG_FORMAT=${LOG_FORMAT:-json}
|
||||
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ services:
|
|||
- GEOLITE_PATH=${GEOLITE_PATH:-/app/testdata/GeoLite2-City.mmdb}
|
||||
- WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-}
|
||||
- MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false}
|
||||
- TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-}
|
||||
- LOG_LEVEL=debug
|
||||
- LOG_FORMAT=text
|
||||
- OTEL_ENABLED=${OTEL_ENABLED:-true}
|
||||
|
|
|
|||
Loading…
Reference in New Issue