diff --git a/PROJECTS/beginner/canary-token-generator/.env.example b/PROJECTS/beginner/canary-token-generator/.env.example index e7aa0c74..f65a2f0d 100644 --- a/PROJECTS/beginner/canary-token-generator/.env.example +++ b/PROJECTS/beginner/canary-token-generator/.env.example @@ -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) # --------------------------------------------------------------------------- diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go index a1b9096e..78eb9ddd 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go @@ -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") diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config_test.go new file mode 100644 index 00000000..5d877b9d --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/PROJECTS/beginner/canary-token-generator/compose.yml b/PROJECTS/beginner/canary-token-generator/compose.yml index 80b5b70e..24031698 100644 --- a/PROJECTS/beginner/canary-token-generator/compose.yml +++ b/PROJECTS/beginner/canary-token-generator/compose.yml @@ -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} diff --git a/PROJECTS/beginner/canary-token-generator/dev.compose.yml b/PROJECTS/beginner/canary-token-generator/dev.compose.yml index a4054b44..de20ed4e 100644 --- a/PROJECTS/beginner/canary-token-generator/dev.compose.yml +++ b/PROJECTS/beginner/canary-token-generator/dev.compose.yml @@ -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}