feat(gateway): temporary IP ban (fail2ban) fed by rejections + honeypot/honeytoken
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
Add a prod-only, in-memory IP ban enforced at the edge, fed by three signals:
sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the
user class stays the backend soft-flag's concern), a honeypot decoy-path hit (the
contour caddy tags decoys with X-Scrabble-Honeypot and routes them to the gateway),
and a honeytoken (a planted bearer, GATEWAY_HONEYTOKEN). A banned IP is refused with
429 by the abuseGuard middleware before any work — covering the Connect edge, the
live stream and the static SPA/landing the per-op limiter never gated.
The ban is off by default: it keys by the real client IP the shared-NAT test contour
does not expose, so a ban there would be self-inflicted; detection still logs in the
contour, only the ban action is gated (GATEWAY_ABUSE_BAN_ENABLED). Rejection bans last
GATEWAY_ABUSE_BAN_DURATION; tripwire/honeytoken hits are near-zero-false-positive and
earn longer fixed bans. Each ban increments gateway_abuse_banned_total{reason}.
Operators see and lift active bans on the admin console's Throttled page; the gateway
syncs its active set to the backend every 30s (POST /api/v1/internal/bans/sync,
backend/internal/banview) and applies the operator unbans the response returns.
PRERELEASE phase AG. Docs baked into ARCHITECTURE / FUNCTIONAL (+ru) / both READMEs.
This commit is contained in:
@@ -46,6 +46,8 @@ type Config struct {
|
||||
MaxBodyBytes int
|
||||
// RateLimit configures the in-memory anti-abuse limiter.
|
||||
RateLimit RateLimitConfig
|
||||
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||
Abuse AbuseConfig
|
||||
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
|
||||
Telemetry pkgtel.Config
|
||||
}
|
||||
@@ -87,8 +89,32 @@ type RateLimitConfig struct {
|
||||
EmailBurst int
|
||||
}
|
||||
|
||||
// AbuseConfig configures the gateway's temporary IP ban (fail2ban-style) and the
|
||||
// honeytoken trap. BanEnabled gates the ban action and is off by default: it is
|
||||
// only safe where the real client IP is visible (i.e. in prod, not behind the
|
||||
// shared-NAT test contour). Detection of honeypot/honeytoken hits is logged
|
||||
// regardless of BanEnabled — only the ban action is gated.
|
||||
type AbuseConfig struct {
|
||||
// BanEnabled turns the IP ban on. Off by default (prod-only).
|
||||
BanEnabled bool
|
||||
// BanThreshold is the rate-limiter rejection count within BanWindow that bans
|
||||
// a client IP.
|
||||
BanThreshold int
|
||||
// BanWindow is the rolling window the rejection strikes accumulate over.
|
||||
BanWindow time.Duration
|
||||
// BanDuration is the length of a rejection-earned ban (tripwire and honeytoken
|
||||
// bans use their own, longer, fixed durations).
|
||||
BanDuration time.Duration
|
||||
// Honeytoken, when non-empty, is a planted bearer value: presenting it bans the
|
||||
// caller and raises a high-severity alarm. Empty disables the trap.
|
||||
Honeytoken string
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
const (
|
||||
defaultAbuseBanThreshold = 100
|
||||
defaultAbuseBanWindow = 2 * time.Minute
|
||||
defaultAbuseBanDuration = 15 * time.Minute
|
||||
defaultHTTPAddr = ":8081"
|
||||
defaultLogLevel = "info"
|
||||
defaultBackendHTTPURL = "http://localhost:8080"
|
||||
@@ -120,6 +146,17 @@ func DefaultRateLimit() RateLimitConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultAbuse returns the built-in anti-abuse settings: the ban disabled
|
||||
// (prod-only) with the agreed thresholds, and no honeytoken.
|
||||
func DefaultAbuse() AbuseConfig {
|
||||
return AbuseConfig{
|
||||
BanEnabled: false,
|
||||
BanThreshold: defaultAbuseBanThreshold,
|
||||
BanWindow: defaultAbuseBanWindow,
|
||||
BanDuration: defaultAbuseBanDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the configuration from the environment, applies defaults, and
|
||||
// validates the result.
|
||||
func Load() (Config, error) {
|
||||
@@ -134,6 +171,7 @@ func Load() (Config, error) {
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
Abuse: DefaultAbuse(),
|
||||
BotLink: BotLinkConfig{
|
||||
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
|
||||
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
|
||||
@@ -162,6 +200,19 @@ func Load() (Config, error) {
|
||||
if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c.Abuse.Honeytoken = os.Getenv("GATEWAY_HONEYTOKEN")
|
||||
if c.Abuse.BanEnabled, err = envBool("GATEWAY_ABUSE_BAN_ENABLED", c.Abuse.BanEnabled); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.Abuse.BanThreshold, err = envInt("GATEWAY_ABUSE_BAN_THRESHOLD", c.Abuse.BanThreshold); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.Abuse.BanWindow, err = envDuration("GATEWAY_ABUSE_BAN_WINDOW", c.Abuse.BanWindow); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
@@ -199,6 +250,9 @@ func (c Config) validate() error {
|
||||
if c.MaxBodyBytes <= 0 {
|
||||
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
|
||||
}
|
||||
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
||||
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
||||
}
|
||||
if c.BotLink.Addr != "" {
|
||||
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
|
||||
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
|
||||
@@ -219,6 +273,20 @@ func envOr(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// envBool parses the environment variable named key as a bool, returning fallback
|
||||
// when it is unset and an error when it is set but malformed.
|
||||
func envBool(key string, fallback bool) (bool, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("config: %s: %w", key, err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// envInt parses the environment variable named key as an int, returning fallback
|
||||
// when it is unset and an error when it is set but malformed.
|
||||
func envInt(key string, fallback int) (int, error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pkgtel "scrabble/pkg/telemetry"
|
||||
)
|
||||
@@ -45,3 +46,52 @@ func TestLoadMaxBodyBytes(t *testing.T) {
|
||||
t.Fatal("Load: expected an error for a non-positive body cap, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
|
||||
// the agreed thresholds, and no honeytoken.
|
||||
func TestLoadAbuseDefaults(t *testing.T) {
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
want := DefaultAbuse()
|
||||
if c.Abuse != want {
|
||||
t.Errorf("Abuse = %+v, want %+v", c.Abuse, want)
|
||||
}
|
||||
if c.Abuse.BanEnabled {
|
||||
t.Error("ban must default to disabled (enabled only in prod)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAbuseOverrides verifies the anti-abuse environment variables are parsed.
|
||||
func TestLoadAbuseOverrides(t *testing.T) {
|
||||
t.Setenv("GATEWAY_ABUSE_BAN_ENABLED", "true")
|
||||
t.Setenv("GATEWAY_ABUSE_BAN_THRESHOLD", "50")
|
||||
t.Setenv("GATEWAY_ABUSE_BAN_WINDOW", "90s")
|
||||
t.Setenv("GATEWAY_ABUSE_BAN_DURATION", "30m")
|
||||
t.Setenv("GATEWAY_HONEYTOKEN", "deadbeef")
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
want := AbuseConfig{
|
||||
BanEnabled: true,
|
||||
BanThreshold: 50,
|
||||
BanWindow: 90 * time.Second,
|
||||
BanDuration: 30 * time.Minute,
|
||||
Honeytoken: "deadbeef",
|
||||
}
|
||||
if c.Abuse != want {
|
||||
t.Errorf("Abuse = %+v, want %+v", c.Abuse, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAbuseRejectsBadThreshold verifies an enabled ban with a non-positive
|
||||
// threshold fails validation.
|
||||
func TestLoadAbuseRejectsBadThreshold(t *testing.T) {
|
||||
t.Setenv("GATEWAY_ABUSE_BAN_ENABLED", "true")
|
||||
t.Setenv("GATEWAY_ABUSE_BAN_THRESHOLD", "0")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for an enabled ban with a zero threshold, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user