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:
CarterPerez-dev 2026-07-19 02:45:10 -04:00
parent b705f1ddd1
commit 97da7fb3a0
5 changed files with 109 additions and 1 deletions

View File

@ -23,6 +23,16 @@ PUBLIC_BASE_URL=https://canary.your.domain
VITE_APP_TITLE="Canary Token Generator" VITE_APP_TITLE="Canary Token Generator"
VITE_API_URL=/api 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) # Cloudflare Turnstile (free anti-bot)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -5,6 +5,7 @@ package config
import ( import (
"fmt" "fmt"
"strings"
"sync" "sync"
"time" "time"
@ -159,7 +160,7 @@ func Load(configPath string) (*Config, error) {
} }
if err := k.Load( if err := k.Load(
env.Provider("", ".", envKeyReplacer), env.ProviderWithValue("", ".", envCallback),
nil, nil,
); err != nil { ); err != nil {
loadErr = fmt.Errorf("load env vars: %w", err) loadErr = fmt.Errorf("load env vars: %w", err)
@ -315,10 +316,14 @@ var envKeyMap = map[string]string{
"CANARY_BASE_URL": "canary.base_url", "CANARY_BASE_URL": "canary.base_url",
"PUBLIC_BASE_URL": "canary.base_url", "PUBLIC_BASE_URL": "canary.base_url",
"CANARY_MANAGE_URL": "canary.manage_url", "CANARY_MANAGE_URL": "canary.manage_url",
"TRUSTED_PROXY_CIDRS": "server.trusted_proxy_cidrs",
"TURNSTILE_SECRET_KEY": "turnstile.secret_key", "TURNSTILE_SECRET_KEY": "turnstile.secret_key",
"TURNSTILE_SECRET": "turnstile.secret_key",
"TURNSTILE_SITE_KEY": "turnstile.site_key", "TURNSTILE_SITE_KEY": "turnstile.site_key",
"MYSQL_ENABLED": "mysql.enabled", "MYSQL_ENABLED": "mysql.enabled",
"MYSQL_FAKE_ENABLED": "mysql.enabled",
"MYSQL_ADDR": "mysql.addr", "MYSQL_ADDR": "mysql.addr",
"MYSQL_FAKE_ADDR": "mysql.addr",
"MYSQL_PUBLIC_HOST": "mysql.public_host", "MYSQL_PUBLIC_HOST": "mysql.public_host",
"MYSQL_PUBLIC_PORT": "mysql.public_port", "MYSQL_PUBLIC_PORT": "mysql.public_port",
"RATE_LIMIT_CREATE_MIN_RATE": "rate_limit.create_min_rate", "RATE_LIMIT_CREATE_MIN_RATE": "rate_limit.create_min_rate",
@ -339,6 +344,10 @@ var envKeyMap = map[string]string{
"GEOLITE_PATH": "geoip.path", "GEOLITE_PATH": "geoip.path",
} }
var envSliceKeys = map[string]struct{}{
"server.trusted_proxy_cidrs": {},
}
func envKeyReplacer(s string) string { func envKeyReplacer(s string) string {
if mapped, ok := envKeyMap[s]; ok { if mapped, ok := envKeyMap[s]; ok {
return mapped return mapped
@ -346,6 +355,27 @@ func envKeyReplacer(s string) string {
return "" 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 { func validate(c *Config) error {
if c.Database.URL == "" { if c.Database.URL == "" {
return fmt.Errorf("DATABASE_URL is required") return fmt.Errorf("DATABASE_URL is required")

View File

@ -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)
}
})
}
}

View File

@ -51,6 +51,7 @@ services:
- GEOLITE_PATH=/data/GeoLite2-City.mmdb - GEOLITE_PATH=/data/GeoLite2-City.mmdb
- WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-} - WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-}
- MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false} - MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false}
- TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-}
- LOG_LEVEL=${LOG_LEVEL:-info} - LOG_LEVEL=${LOG_LEVEL:-info}
- LOG_FORMAT=${LOG_FORMAT:-json} - LOG_FORMAT=${LOG_FORMAT:-json}
- OTEL_ENABLED=${OTEL_ENABLED:-false} - OTEL_ENABLED=${OTEL_ENABLED:-false}

View File

@ -64,6 +64,7 @@ services:
- GEOLITE_PATH=${GEOLITE_PATH:-/app/testdata/GeoLite2-City.mmdb} - GEOLITE_PATH=${GEOLITE_PATH:-/app/testdata/GeoLite2-City.mmdb}
- WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-} - WEBHOOK_HMAC_SECRET=${WEBHOOK_HMAC_SECRET:-}
- MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false} - MYSQL_FAKE_ENABLED=${MYSQL_FAKE_ENABLED:-false}
- TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-}
- LOG_LEVEL=debug - LOG_LEVEL=debug
- LOG_FORMAT=text - LOG_FORMAT=text
- OTEL_ENABLED=${OTEL_ENABLED:-true} - OTEL_ENABLED=${OTEL_ENABLED:-true}