From 041106d6239d9a56492a69e26275c9f0e147c418 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 08:54:20 +0200 Subject: [PATCH 1/3] feat(gateway): temporary IP ban (fail2ban) fed by rejections + honeypot/honeytoken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PRERELEASE.md | 14 ++ backend/README.md | 8 + backend/cmd/backend/main.go | 6 + .../templates/pages/throttled.gohtml | 20 ++ backend/internal/adminconsole/views.go | 18 +- backend/internal/banview/banview.go | 92 ++++++++ backend/internal/banview/banview_test.go | 64 +++++ backend/internal/server/handlers.go | 5 + .../internal/server/handlers_admin_console.go | 23 ++ backend/internal/server/handlers_bans.go | 48 ++++ backend/internal/server/server.go | 7 + deploy/caddy/Caddyfile | 21 +- deploy/docker-compose.yml | 7 + docs/ARCHITECTURE.md | 24 +- docs/FUNCTIONAL.md | 10 +- docs/FUNCTIONAL_ru.md | 9 +- gateway/README.md | 20 +- gateway/cmd/gateway/main.go | 41 ++++ gateway/internal/backendclient/client.go | 19 ++ gateway/internal/backendclient/client_test.go | 36 +++ gateway/internal/config/config.go | 68 ++++++ gateway/internal/config/config_test.go | 50 ++++ gateway/internal/connectsrv/abuse_test.go | 219 ++++++++++++++++++ gateway/internal/connectsrv/metrics.go | 14 +- gateway/internal/connectsrv/metrics_test.go | 38 +++ gateway/internal/connectsrv/server.go | 79 ++++++- gateway/internal/ratelimit/banlist.go | 211 +++++++++++++++++ gateway/internal/ratelimit/banlist_test.go | 143 ++++++++++++ 28 files changed, 1295 insertions(+), 19 deletions(-) create mode 100644 backend/internal/banview/banview.go create mode 100644 backend/internal/banview/banview_test.go create mode 100644 backend/internal/server/handlers_bans.go create mode 100644 gateway/internal/connectsrv/abuse_test.go create mode 100644 gateway/internal/ratelimit/banlist.go create mode 100644 gateway/internal/ratelimit/banlist_test.go diff --git a/PRERELEASE.md b/PRERELEASE.md index a47efd7..20ed405 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -40,6 +40,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** | | DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** | | TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) | +| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **in progress** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) @@ -83,6 +84,19 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l - **Rate-abuse (TODO 8):** metric + Grafana + admin view **plus a conservative auto-flag** — a *soft, reversible* "suspected high-rate" marker for operator review, tunable threshold, **no auto-ban**. +- **Anti-abuse IP ban (AG, owner ad-hoc):** a honeypot was considered and rejected as a *DDoS* + defence — it detects/deceives but does not shed volumetric load, cannot cover the real + endpoints, and a tarpit backfires under flood; volumetric L3/L4 is an upstream/CDN concern, + out of scope. The effective layer is a **temporary IP ban** (fail2ban-style) that the honeypot + and honeytoken merely *feed*. This does **not** reverse the TODO-8 "no auto-ban": that decision + governs the **account** soft-flag (still never a gate); the IP ban is a separate, IP-keyed, + **prod-only** layer with an **operator unban** in the console. Decisions: banlist lives in the + existing `ratelimit` package (smallest surface); the decoy path list is a **single source of + truth in the caddy** (it tags requests with a header — the gateway keeps no second list); + bans are in-memory + single-instance (like `ratewatch`), auto-expiring, **plus** an admin + console view + manual unban over a bidirectional 30 s sync (operator control = owner's choice). + An active-bans Grafana **gauge** was trimmed (the console view + the `gateway_abuse_banned_total` + counter cover it) to keep the diff focused. - **Open auto-match (owner ad-hoc):** a quick game **enters a real game at once and waits inside it** (status `open`, the opponent seat empty); a second human searching the same variant+rule joins it, or a robot fills it after a **90 s + random 0–90 s** wait, pushing the in-app diff --git a/backend/README.md b/backend/README.md index 6da397f..bde9d55 100644 --- a/backend/README.md +++ b/backend/README.md @@ -148,6 +148,13 @@ rejected calls within `BACKEND_HIGHRATE_FLAG_WINDOW` gets the soft, reversible `accounts.flagged_high_rate_at` marker (set-once; a badge in the user list and a **Clear** action on the user card; never an automatic ban). +The gateway also syncs its active IP bans (prod-only — see ARCHITECTURE §11) to +`POST /api/v1/internal/bans/sync`; `internal/banview` mirrors them for the console's +**Throttled** page (an **Active IP bans** panel with an **Unban** action) and returns +the operator's pending unbans in the response, which the gateway applies on its next +sync. Like `ratewatch` it is in-memory and resets on restart — the enforced ban lives +in the gateway, not here. + ## Package layout ``` @@ -173,6 +180,7 @@ internal/adminconsole/ # server-rendered admin console (Go templates + embedded internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet) internal/connector/ # backend gRPC client to the gateway bot-link relay (operator broadcasts) internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag +internal/banview/ # gateway active-ban mirror: the console's Active IP bans panel + the operator unban backchannel ``` ## Configuration (environment) diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 0dce367..c4c17a4 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -20,6 +20,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/accountmerge" "scrabble/backend/internal/ads" + "scrabble/backend/internal/banview" "scrabble/backend/internal/config" "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" @@ -211,6 +212,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { zap.Int("flag_threshold", cfg.RateWatch.FlagThreshold), zap.Duration("flag_window", cfg.RateWatch.FlagWindow)) + // Ban observability: mirror the gateway's active IP bans for the admin console's + // active-bans panel and collect operator unban requests. + banView := banview.New() + // Advertising-banner domain: campaign rotation feeding the profile.get banner // block and the banner admin console section. adsSvc := ads.NewService(ads.NewStore(db)) @@ -233,6 +238,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { DictDir: cfg.Game.DictDir, Connector: conn, RateWatch: rateWatch, + BanView: banView, Ads: adsSvc, Notifier: hub, }) diff --git a/backend/internal/adminconsole/templates/pages/throttled.gohtml b/backend/internal/adminconsole/templates/pages/throttled.gohtml index 696b339..a2c5bdd 100644 --- a/backend/internal/adminconsole/templates/pages/throttled.gohtml +++ b/backend/internal/adminconsole/templates/pages/throttled.gohtml @@ -5,6 +5,26 @@ list is in-memory and resets on a backend restart. An account sustaining {{.FlagThreshold}}+ rejected calls within {{.FlagWindow}} is soft-flagged for review below — never banned automatically; clear the flag on the user card.

+

Active IP bans

+

Temporary IP bans the gateway is currently enforcing (in-memory, prod-only; +reset on a gateway restart). Unban applies on the gateway's next sync.

+ + + +{{range .Bans}} + + + + + + + +{{else}} + +{{end}} + +
IPReasonSinceExpires
{{.IP}}{{.Reason}}{{.Since}}{{.Expires}}
no active bans
+

Recent episodes

diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index eb3aafb..48a85bb 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -389,17 +389,27 @@ type BroadcastView struct { ConnectorEnabled bool } -// ThrottledView is the rate-limit observability page: the recent gateway-reported -// throttle episodes (in-memory, reset on restart) and the accounts currently -// carrying the high-rate flag. FlagThreshold and FlagWindow caption the active -// auto-flag tuning. +// ThrottledView is the rate-limit observability page: the temporary IP bans the +// gateway is currently enforcing, the recent gateway-reported throttle episodes +// (in-memory, reset on restart) and the accounts currently carrying the high-rate +// flag. FlagThreshold and FlagWindow caption the active auto-flag tuning. type ThrottledView struct { + Bans []BanRow Episodes []ThrottleEpisodeRow Flagged []FlaggedAccountRow FlagThreshold int FlagWindow string } +// BanRow is one temporary IP ban the gateway is enforcing, with its reason and its +// since/expiry timestamps; the row carries an unban action. +type BanRow struct { + IP string + Reason string + Since string + Expires string +} + // ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the // user card and is set only for the user class (the other classes key by IP). type ThrottleEpisodeRow struct { diff --git a/backend/internal/banview/banview.go b/backend/internal/banview/banview.go new file mode 100644 index 0000000..325c555 --- /dev/null +++ b/backend/internal/banview/banview.go @@ -0,0 +1,92 @@ +// Package banview mirrors the gateway's active IP bans for the admin console and +// collects operator unban requests for the gateway to apply. Like ratewatch it is +// in-memory, single-instance and resets on a backend restart by design — the +// gateway re-reports its active set on the next sync, and the durable effect (the +// ban itself) lives in the gateway, not here. +package banview + +import ( + "sort" + "sync" + "time" +) + +// Ban is one active IP ban as reported by the gateway. +type Ban struct { + IP string + Reason string + Since time.Time + Expires time.Time +} + +// View holds the last-reported active bans and the operator's pending unbans. +type View struct { + now func() time.Time + + mu sync.Mutex + bans map[string]Ban // last reported active set, keyed by IP + unban map[string]struct{} // IPs an operator marked for unban +} + +// New constructs an empty View. +func New() *View { + return &View{now: time.Now, bans: make(map[string]Ban), unban: make(map[string]struct{})} +} + +// Ingest replaces the mirrored active set with the gateway's latest report, +// skipping entries with an empty IP or one that has already expired. +func (v *View) Ingest(active []Ban) { + now := v.now() + v.mu.Lock() + defer v.mu.Unlock() + v.bans = make(map[string]Ban, len(active)) + for _, b := range active { + if b.IP == "" || !now.Before(b.Expires) { + continue + } + v.bans[b.IP] = b + } +} + +// Recent returns the mirrored active bans, most recently banned first. +func (v *View) Recent() []Ban { + now := v.now() + v.mu.Lock() + defer v.mu.Unlock() + out := make([]Ban, 0, len(v.bans)) + for _, b := range v.bans { + if now.Before(b.Expires) { + out = append(out, b) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) }) + return out +} + +// RequestUnban records an operator request to lift the ban on ip; the gateway +// applies it on its next sync (so the console reflects it within the sync +// interval). An empty ip is ignored. +func (v *View) RequestUnban(ip string) { + if ip == "" { + return + } + v.mu.Lock() + defer v.mu.Unlock() + v.unban[ip] = struct{}{} +} + +// DrainUnbans returns and clears the IPs operators have marked for unban since the +// previous drain. It returns nil when there are none. +func (v *View) DrainUnbans() []string { + v.mu.Lock() + defer v.mu.Unlock() + if len(v.unban) == 0 { + return nil + } + out := make([]string, 0, len(v.unban)) + for ip := range v.unban { + out = append(out, ip) + } + clear(v.unban) + return out +} diff --git a/backend/internal/banview/banview_test.go b/backend/internal/banview/banview_test.go new file mode 100644 index 0000000..e647244 --- /dev/null +++ b/backend/internal/banview/banview_test.go @@ -0,0 +1,64 @@ +package banview + +import ( + "testing" + "time" +) + +func viewAt(clk *time.Time) *View { + v := New() + v.now = func() time.Time { return *clk } + return v +} + +func TestIngestRecentDropsExpired(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + v := viewAt(&clk) + v.Ingest([]Ban{ + {IP: "1.1.1.1", Reason: "tripwire", Since: clk, Expires: clk.Add(time.Hour)}, + {IP: "2.2.2.2", Reason: "rejections", Since: clk.Add(-2 * time.Hour), Expires: clk.Add(-time.Hour)}, // expired + {IP: "", Reason: "x", Since: clk, Expires: clk.Add(time.Hour)}, // empty IP + }) + got := v.Recent() + if len(got) != 1 || got[0].IP != "1.1.1.1" || got[0].Reason != "tripwire" { + t.Fatalf("Recent = %+v, want one live ban for 1.1.1.1", got) + } +} + +func TestIngestReplaces(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + v := viewAt(&clk) + v.Ingest([]Ban{{IP: "1.1.1.1", Since: clk, Expires: clk.Add(time.Hour)}}) + v.Ingest([]Ban{{IP: "2.2.2.2", Since: clk, Expires: clk.Add(time.Hour)}}) + got := v.Recent() + if len(got) != 1 || got[0].IP != "2.2.2.2" { + t.Fatalf("Recent = %+v, want only the latest report (2.2.2.2)", got) + } +} + +func TestRecentOrdersBySince(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + v := viewAt(&clk) + v.Ingest([]Ban{ + {IP: "old", Since: clk.Add(-10 * time.Minute), Expires: clk.Add(time.Hour)}, + {IP: "new", Since: clk.Add(-1 * time.Minute), Expires: clk.Add(time.Hour)}, + }) + got := v.Recent() + if len(got) != 2 || got[0].IP != "new" || got[1].IP != "old" { + t.Fatalf("Recent order = %+v, want most recent first", got) + } +} + +func TestUnbanRoundTrip(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + v := viewAt(&clk) + v.RequestUnban("3.3.3.3") + v.RequestUnban("") // ignored + drained := v.DrainUnbans() + if len(drained) != 1 || drained[0] != "3.3.3.3" { + t.Fatalf("DrainUnbans = %v, want [3.3.3.3]", drained) + } + if again := v.DrainUnbans(); again != nil { + t.Fatalf("second DrainUnbans = %v, want nil (cleared)", again) + } +} diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 8b0099c..118fdbf 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -42,6 +42,11 @@ func (s *Server) registerRoutes() { // admin console's throttled view and the high-rate auto-flag. s.internal.POST("/ratelimit/report", s.handleRateLimitReport) } + if s.banview != nil { + // The gateway's periodic active-ban sync: feeds the admin console's + // active-bans panel and returns the operator's pending unbans. + s.internal.POST("/bans/sync", s.handleBanSync) + } u := s.user if s.accounts != nil { u.GET("/profile", s.handleProfile) diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index fac9fde..cb6ed2f 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -66,6 +66,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/reasons/:id/update", s.consoleUpdateReason) gm.POST("/reasons/:id/delete", s.consoleDeleteReason) gm.GET("/throttled", s.consoleThrottled) + gm.POST("/bans/unban", s.consoleUnban) gm.GET("/games", s.consoleGames) gm.GET("/games/:id", s.consoleGameDetail) gm.GET("/complaints", s.consoleComplaints) @@ -874,6 +875,13 @@ func (s *Server) consoleThrottled(c *gin.Context) { view.Episodes = append(view.Episodes, row) } } + if s.banview != nil { + for _, b := range s.banview.Recent() { + view.Bans = append(view.Bans, adminconsole.BanRow{ + IP: b.IP, Reason: b.Reason, Since: fmtTime(b.Since), Expires: fmtTime(b.Expires), + }) + } + } flagged, err := s.accounts.ListFlaggedHighRate(ctx) if err != nil { s.consoleError(c, err) @@ -887,6 +895,21 @@ func (s *Server) consoleThrottled(c *gin.Context) { s.renderConsole(c, "throttled", "throttled", "Throttled", view) } +// consoleUnban lifts a temporary IP ban — the operator's manual override. The +// gateway applies it on its next active-ban sync, so the ban clears within the +// sync interval rather than immediately. +func (s *Server) consoleUnban(c *gin.Context) { + ip := trimForm(c, "ip") + if ip == "" { + s.renderConsoleMessage(c, "Invalid", "an IP address is required", "/_gm/throttled") + return + } + if s.banview != nil { + s.banview.RequestUnban(ip) + } + s.renderConsoleMessage(c, "Unban requested", fmt.Sprintf("%s will be unbanned on the next gateway sync", ip), "/_gm/throttled") +} + // consoleClearHighRateFlag clears the soft high-rate marker — the operator's // reversible review action. func (s *Server) consoleClearHighRateFlag(c *gin.Context) { diff --git a/backend/internal/server/handlers_bans.go b/backend/internal/server/handlers_bans.go new file mode 100644 index 0000000..5db8480 --- /dev/null +++ b/backend/internal/server/handlers_bans.go @@ -0,0 +1,48 @@ +package server + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "scrabble/backend/internal/banview" +) + +// banSyncRequest mirrors the gateway's active-ban report: every entry is one +// currently-enforced IP ban. +type banSyncRequest struct { + Active []banSyncEntry `json:"active"` +} + +// banSyncEntry is one active ban in the sync request. +type banSyncEntry struct { + IP string `json:"ip"` + Reason string `json:"reason"` + Since time.Time `json:"since"` + Expires time.Time `json:"expires"` +} + +// banSyncResponse returns the IPs an operator has marked for unban for the gateway +// to apply on its next sync. +type banSyncResponse struct { + Unban []string `json:"unban"` +} + +// handleBanSync ingests the gateway's active-ban report into the ban view (the +// admin console's active-bans panel) and returns the operator's pending unbans. +// Internal, gateway-only: like the rate-limit report it trusts the network +// segment and carries no user identity. +func (s *Server) handleBanSync(c *gin.Context) { + var req banSyncRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid ban sync") + return + } + bans := make([]banview.Ban, 0, len(req.Active)) + for _, e := range req.Active { + bans = append(bans, banview.Ban{IP: e.IP, Reason: e.Reason, Since: e.Since, Expires: e.Expires}) + } + s.banview.Ingest(bans) + c.JSON(http.StatusOK, banSyncResponse{Unban: s.banview.DrainUnbans()}) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 81d1287..ecc8971 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -20,6 +20,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/adminconsole" "scrabble/backend/internal/ads" + "scrabble/backend/internal/banview" "scrabble/backend/internal/connector" "scrabble/backend/internal/engine" "scrabble/backend/internal/feedback" @@ -83,6 +84,10 @@ type Deps struct { // admin console's throttled view + the high-rate auto-flag. A nil RateWatch // disables the internal report endpoint and the console view. RateWatch *ratewatch.Watch + // BanView mirrors the gateway's active IP bans for the admin console and + // collects operator unban requests. A nil BanView disables the internal + // ban-sync endpoint and the console's active-bans panel. + BanView *banview.View // Ads is the advertising-banner domain service: campaign rotation feeding the // profile.get banner block, plus the banner admin console section. A nil Ads // omits the banner block and disables the banner console. @@ -115,6 +120,7 @@ type Server struct { dictDir string connector *connector.Client ratewatch *ratewatch.Watch + banview *banview.View ads *ads.Service notifier notify.Publisher console *adminconsole.Renderer @@ -164,6 +170,7 @@ func New(addr string, deps Deps) *Server { dictDir: deps.DictDir, connector: deps.Connector, ratewatch: deps.RateWatch, + banview: deps.BanView, ads: deps.Ads, notifier: notifier, http: &http.Server{Addr: addr, Handler: engine}, diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 3d9fced..80189f2 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -38,10 +38,27 @@ } } - # The game SPA and the Connect edge are served by the gateway. + # The game SPA and the Connect edge are served by the gateway. Strip any + # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the + # tag the honeypot block sets below (a client cannot self-tag a real request). @gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/* handle @gateway { - reverse_proxy gateway:8081 + reverse_proxy gateway:8081 { + header_up -X-Scrabble-Honeypot + } + } + + # Honeypot decoy paths: classic vulnerability-scanner bait no real client ever + # requests. Route them to the gateway tagged with X-Scrabble-Honeypot so it logs + # the scanner hit and (in prod) bans the source IP. The inbound header is + # stripped first so a client cannot pre-set it — and even if it did, it would + # only ban itself. Keep this list in sync with no legitimate landing/app path. + @honeypot path /.env /.git /.git/* /.aws/* /wp-login.php /wp-admin /wp-admin/* /phpmyadmin /phpmyadmin/* + handle @honeypot { + reverse_proxy gateway:8081 { + header_up -X-Scrabble-Honeypot + header_up X-Scrabble-Honeypot 1 + } } # Everything else — the public landing at / and any stray path — is static. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index cf13444..664efa8 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -150,6 +150,13 @@ services: GATEWAY_BOTLINK_TLS_CERT: /certs/gateway.crt GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt + # Anti-abuse IP ban (fail2ban-style), fed by rate-limit rejections and the + # honeypot/honeytoken. Off by default: it bans by client IP, which is only + # real in prod — the test contour arrives as one shared NAT address, so a ban + # there would be self-inflicted (the honeypot/honeytoken still log). Prod sets + # these from PROD_ inputs; GATEWAY_HONEYTOKEN is the planted bearer trap. + GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false} + GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-} GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info} GATEWAY_SERVICE_NAME: scrabble-gateway GATEWAY_OTEL_TRACES_EXPORTER: otlp diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fc117d3..278bf19 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -922,6 +922,28 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid list/detail, cleared by the operator, **never an automatic ban** and never a request gate. The Edge/UX dashboard graphs the aggregate request rate against the rejection rate by class. +- **Temporary IP ban (prod-only):** with `GATEWAY_ABUSE_BAN_ENABLED` set, the gateway + enforces a fail2ban-style block keyed by client IP, fed by three signals: an IP that + sustains `GATEWAY_ABUSE_BAN_THRESHOLD` rate-limiter rejections within + `GATEWAY_ABUSE_BAN_WINDOW` (the IP-keyed public/email/admin classes — the user class + stays the soft-flag's concern, never the ban's), a **honeypot** decoy-path hit, and a + **honeytoken** (a planted bearer no real client holds, `GATEWAY_HONEYTOKEN`). A banned + IP is refused with **429** by an edge middleware (`abuseGuard`) before any work — + covering the Connect edge, the live stream and the static SPA/landing the per-op limiter + never gated. A rejection ban lasts `GATEWAY_ABUSE_BAN_DURATION`; a tripwire/honeytoken + hit is near-zero-false-positive and earns a longer fixed ban (1 h / 24 h). The ban is + **in-memory, single-instance and resets on restart**, like `ratewatch`; each ban + increments `gateway_abuse_banned_total` (`reason` = rejections/tripwire/honeytoken). The + decoy paths live only in the contour **caddy**, which tags them with `X-Scrabble-Honeypot` + (stripping any client-supplied value) and routes them to the gateway. It is **off by + default and only enabled in prod**: the ban keys by real client IP, which the shared-NAT + test contour does not expose (every client arrives as one address), so a ban there would + be self-inflicted — the honeypot/honeytoken still **log** in the contour, only the ban + *action* is gated. Operators see the active bans and lift them on the admin console's + **Throttled** page; the gateway syncs its active set to the backend every 30 s + (`POST /api/v1/internal/bans/sync`, network-trusted like the rejection report) and applies + the operator unbans the response returns, so a manual unban takes effect within the sync + interval. - Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the database answers a bounded ping and the session cache is warmed). - The backend serves a **second listener** — a gRPC server @@ -932,7 +954,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid | Concern | Enforced by | | --- | --- | -| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11) | +| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) | | Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway | | Session minting; email-code / guest validation | gateway (with backend) | | Session → `user_id` resolution, `X-User-ID` injection | gateway | diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 22c8561..1f4c422 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -295,8 +295,14 @@ recently throttled users/IPs the gateway reported (an in-memory window — it re a backend restart) and the accounts currently carrying the soft **high-rate flag**. An account sustaining rejections past a tunable threshold is flagged automatically — the marker is reversible, shown as a badge in the user list and on the user card, and -**never blocks play**; the operator reviews and clears it from the user card. There is -no automatic ban. +**never blocks play**; the operator reviews and clears it from the user card. The +account flag itself is never a ban. In **production** the same page also lists the +**active IP bans** the gateway is enforcing: a temporary block of a client IP that +floods the service past a threshold, or trips a hidden **honeypot** path or a planted +**honeytoken** — a high-confidence sign of a scanner or hostile bot, never a normal +player. Each ban shows its reason and expiry with an **Unban** action; bans auto-expire +and the operator can lift one early. IP bans are a production-only safeguard — the +shared test environment cannot tell its clients apart, so it does not enforce them. The console also lets an operator **manually block** an account — the hard counterpart to the soft high-rate flag. From the user card the operator blocks the account **permanently** or diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 120f217..f2d817c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -303,7 +303,14 @@ Telegram-identity) или **отправить пост в игровой кан флагом**. Аккаунт, устойчиво превышающий настраиваемый порог отказов, помечается автоматически — маркер обратим, виден бейджем в списке пользователей и на карточке аккаунта и **никогда не блокирует игру**; оператор рассматривает и снимает его с -карточки пользователя. Автоматического бана нет. +карточки пользователя. Сам флаг аккаунта баном не является. В **проде** та же +страница дополнительно перечисляет **активные баны по IP**, которые применяет gateway: +временную блокировку IP клиента, превысившего порог наплыва, либо задевшего скрытую +**honeypot**-ловушку или подброшенный **honeytoken** — высокодостоверный признак +сканера или враждебного бота, но не нормального игрока. У каждого бана показаны причина +и срок, рядом действие **Unban**; баны истекают сами, а оператор может снять бан раньше. +Баны по IP — защита только для прода: общий тестовый контур не различает своих клиентов, +поэтому там не применяется. Консоль также позволяет оператору **вручную заблокировать** аккаунт — жёсткий аналог мягкого high-rate флага. С карточки пользователя оператор блокирует аккаунт **навсегда** или **до даты** diff --git a/gateway/README.md b/gateway/README.md index 35141e9..e317ec8 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -23,7 +23,7 @@ proto/edge/v1/ # Connect envelope contract (committed generated Go) internal/config/ # GATEWAY_* env config internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client internal/session/ # in-memory session cache (LRU/TTL, backend fallback) -internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker +internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker + the temporary IP banlist internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing internal/push/ # live-event fan-out hub (per-user client streams) internal/transcode/ # FlatBuffers<->REST bridge + message_type registry @@ -89,6 +89,11 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These | `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap | | `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) | | `GATEWAY_MAX_BODY_BYTES` | `1048576` | caps one request body and one Connect message read; an oversized Execute is refused with `resource_exhausted` | +| `GATEWAY_ABUSE_BAN_ENABLED` | `false` | enable the temporary IP ban (prod-only — keys by real client IP, off in the shared-NAT test contour) | +| `GATEWAY_ABUSE_BAN_THRESHOLD` | `100` | rate-limiter rejections within the window that ban an IP | +| `GATEWAY_ABUSE_BAN_WINDOW` | `2m` | rolling window the rejection strikes accumulate over | +| `GATEWAY_ABUSE_BAN_DURATION` | `15m` | length of a rejection-earned ban (tripwire 1h, honeytoken 24h are fixed) | +| `GATEWAY_HONEYTOKEN` | unset | planted bearer value; presenting it bans the caller and raises an alarm | | `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` | | `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) | | `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` | @@ -104,6 +109,19 @@ per-key rejection tracker every 30 s, emits a Warn summary per throttled key and posts the report to the backend (`/api/v1/internal/ratelimit/report`), feeding the admin console's throttled view and the high-rate auto-flag. +Temporary IP ban (prod-only, `GATEWAY_ABUSE_BAN_ENABLED`): a fail2ban-style block +keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed classes), +a **honeypot** decoy-path hit (the contour caddy tags decoy paths with +`X-Scrabble-Honeypot`), and a **honeytoken** (`GATEWAY_HONEYTOKEN`). A banned IP is +refused with 429 by the `abuseGuard` edge middleware before any work — covering the +Connect edge, the live stream and the static SPA/landing. Each ban increments +`gateway_abuse_banned_total{reason}` (`rejections`/`tripwire`/`honeytoken`). The ban +is in-memory (resets on restart); it is **off by default** because it keys by the real +client IP, which the shared-NAT test contour does not expose (detection still logs +there, only the ban action is gated). The gateway syncs its active set to the backend +every 30 s (`/api/v1/internal/bans/sync`) for the console's **Active IP bans** panel +and applies the operator unbans the response returns. + ## Run ```sh diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 3b1a741..35e1f65 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -68,6 +68,9 @@ const ( // throttleReportInterval is the cadence of the rate-limiter rejection // summary: the Warn log per throttled key and the report to the backend. throttleReportInterval = 30 * time.Second + // banSyncInterval is the cadence of the active-ban sync to the backend (which + // feeds the admin-console view) and the operator-unban pull. + banSyncInterval = 30 * time.Second ) func main() { @@ -119,6 +122,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { sessions := session.NewCache(backend, cfg.SessionTTL, cfg.SessionCacheMax) limiter := ratelimit.New() tracker := ratelimit.NewTracker() + banlist := ratelimit.NewBanlist(ratelimit.BanConfig{ + Enabled: cfg.Abuse.BanEnabled, + Threshold: cfg.Abuse.BanThreshold, + Window: cfg.Abuse.BanWindow, + Duration: cfg.Abuse.BanDuration, + }) hub := push.NewHub(0) var validator transcode.TelegramValidator @@ -183,6 +192,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Sessions: sessions, Limiter: limiter, Tracker: tracker, + Banlist: banlist, + Honeytoken: cfg.Abuse.Honeytoken, Hub: hub, RateLimit: cfg.RateLimit, Heartbeat: cfg.PushHeartbeatInterval, @@ -197,6 +208,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { go runPushPump(ctx, backend, hub, botHub, logger) // Periodically summarise rate-limiter rejections (Warn log + backend report). go runThrottleReporter(ctx, tracker, backend, logger) + // When the IP ban is enabled (prod), sync the active set to the backend (the + // admin-console view) and apply the operator unbans it returns. + if cfg.Abuse.BanEnabled { + go runBanSync(ctx, banlist, backend, logger) + } public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout} servers := []*namedServer{{name: "public", srv: public}} @@ -298,6 +314,31 @@ func runThrottleReporter(ctx context.Context, tracker *ratelimit.Tracker, backen } } +// runBanSync periodically reports the gateway's active IP bans to the backend (the +// admin-console view) and applies the operator unbans it returns, until the +// context is done. A failed sync is logged and dropped — the next tick reports +// fresh state, and a missed unban is retried on it. +func runBanSync(ctx context.Context, banlist *ratelimit.Banlist, backend *backendclient.Client, logger *zap.Logger) { + ticker := time.NewTicker(banSyncInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + unban, err := backend.SyncBans(ctx, banlist.Active()) + if err != nil { + logger.Warn("ban sync failed", zap.Error(err)) + continue + } + for _, ip := range unban { + banlist.Unban(ip) + logger.Info("ban cleared by operator", zap.String("client_ip", ip)) + } + } +} + // runPushPump keeps a backend push subscription open, forwarding every event to // the hub and re-subscribing after the stream ends, until the context is done. For // the out-of-app push kinds it also routes events whose recipient has no live diff --git a/gateway/internal/backendclient/client.go b/gateway/internal/backendclient/client.go index 56653de..527e71a 100644 --- a/gateway/internal/backendclient/client.go +++ b/gateway/internal/backendclient/client.go @@ -137,3 +137,22 @@ func (c *Client) ReportRateLimited(ctx context.Context, windowSeconds int, entri }{WindowSeconds: windowSeconds, Entries: entries} return c.do(ctx, http.MethodPost, "/api/v1/internal/ratelimit/report", "", "", body, nil) } + +// SyncBans reports the gateway's currently-active IP bans to the backend and +// returns the IPs an operator has marked for unban since the previous sync. It is +// the ban mirror of ReportRateLimited plus the manual-unban backchannel: the +// backend renders the active set in the admin console and drains the operator's +// unban requests into the response. Like the rejection report it carries no user +// identity and rides the trusted internal segment. +func (c *Client) SyncBans(ctx context.Context, active []ratelimit.Ban) ([]string, error) { + body := struct { + Active []ratelimit.Ban `json:"active"` + }{Active: active} + var out struct { + Unban []string `json:"unban"` + } + if err := c.do(ctx, http.MethodPost, "/api/v1/internal/bans/sync", "", "", body, &out); err != nil { + return nil, err + } + return out.Unban, nil +} diff --git a/gateway/internal/backendclient/client_test.go b/gateway/internal/backendclient/client_test.go index 3ad3612..201478d 100644 --- a/gateway/internal/backendclient/client_test.go +++ b/gateway/internal/backendclient/client_test.go @@ -46,3 +46,39 @@ func TestReportRateLimited(t *testing.T) { t.Fatalf("backend received %+v, want window 30 + %+v", got, entries[0]) } } + +// TestSyncBans verifies the gateway reports its active bans to the backend's +// internal endpoint and returns the operator unban list the backend replies with. +func TestSyncBans(t *testing.T) { + var got struct { + Active []ratelimit.Ban `json:"active"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/internal/bans/sync" { + t.Errorf("call = %s %s, want POST /api/v1/internal/bans/sync", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode sync: %v", err) + } + _, _ = w.Write([]byte(`{"unban":["203.0.113.9"]}`)) + })) + defer srv.Close() + + c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second) + if err != nil { + t.Fatalf("backendclient: %v", err) + } + defer func() { _ = c.Close() }() + + active := []ratelimit.Ban{{IP: "198.51.100.4", Reason: ratelimit.ReasonTripwire}} + unban, err := c.SyncBans(context.Background(), active) + if err != nil { + t.Fatalf("SyncBans: %v", err) + } + if len(got.Active) != 1 || got.Active[0].IP != "198.51.100.4" || got.Active[0].Reason != ratelimit.ReasonTripwire { + t.Fatalf("backend received active = %+v, want one tripwire ban for 198.51.100.4", got.Active) + } + if len(unban) != 1 || unban[0] != "203.0.113.9" { + t.Fatalf("unban = %v, want [203.0.113.9]", unban) + } +} diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index e498581..9cb1fee 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -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) { diff --git a/gateway/internal/config/config_test.go b/gateway/internal/config/config_test.go index 9667ea4..60ff87c 100644 --- a/gateway/internal/config/config_test.go +++ b/gateway/internal/config/config_test.go @@ -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") + } +} diff --git a/gateway/internal/connectsrv/abuse_test.go b/gateway/internal/connectsrv/abuse_test.go new file mode 100644 index 0000000..05e65e6 --- /dev/null +++ b/gateway/internal/connectsrv/abuse_test.go @@ -0,0 +1,219 @@ +package connectsrv_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "connectrpc.com/connect" + + "scrabble/gateway/internal/backendclient" + "scrabble/gateway/internal/config" + "scrabble/gateway/internal/connectsrv" + "scrabble/gateway/internal/push" + "scrabble/gateway/internal/ratelimit" + "scrabble/gateway/internal/session" + "scrabble/gateway/internal/transcode" + edgev1 "scrabble/gateway/proto/edge/v1" + "scrabble/gateway/proto/edge/v1/edgev1connect" +) + +const honeypotHeader = "X-Scrabble-Honeypot" + +// guardedEdge wires an edge with an explicit banlist and honeytoken over a fake +// backend, returning the front URL, a Connect client and a cleanup func. +func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) { + t.Helper() + backendSrv := httptest.NewServer(backendHandler) + backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second) + if err != nil { + t.Fatalf("backendclient: %v", err) + } + edge := connectsrv.NewServer(connectsrv.Deps{ + Registry: transcode.NewRegistry(backend, nil), + Sessions: session.NewCache(backend, time.Minute, 100), + Limiter: ratelimit.New(), + Banlist: bl, + Honeytoken: honeytoken, + Hub: push.NewHub(0), + RateLimit: limits, + Heartbeat: 15 * time.Second, + }) + edgeSrv := httptest.NewServer(edge.HTTPHandler()) + client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL) + return edgeSrv.URL, client, func() { + edgeSrv.Close() + _ = backend.Close() + backendSrv.Close() + } +} + +// noRedirect is an HTTP client that surfaces a redirect instead of following it, +// so the test can tell a 308 (passed the guard) from a 429 (blocked). +func noRedirect() *http.Client { + return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} +} + +func enabledBanlist(threshold int) *ratelimit.Banlist { + return ratelimit.NewBanlist(ratelimit.BanConfig{ + Enabled: true, Threshold: threshold, Window: time.Minute, Duration: time.Hour, + }) +} + +// TestAbuseGuardBlocksBannedIP verifies a banned client IP is refused with 429 at +// the HTTP layer, before any handler runs. +func TestAbuseGuardBlocksBannedIP(t *testing.T) { + bl := enabledBanlist(100) + bl.BanNow("127.0.0.1", ratelimit.ReasonTripwire) + url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + resp, err := noRedirect().Get(url + "/") + if err != nil { + t.Fatalf("get: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("banned GET / = %d, want 429", resp.StatusCode) + } +} + +// TestHoneypotHeaderTrips verifies a request carrying the honeypot header is 404'd +// and bans the client IP, so the next request is blocked. +func TestHoneypotHeaderTrips(t *testing.T) { + bl := enabledBanlist(100) + url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) { + t.Error("backend must not be called for a honeypot hit") + }) + defer cleanup() + + req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil) + req.Header.Set(honeypotHeader, "1") + resp, err := noRedirect().Do(req) + if err != nil { + t.Fatalf("honeypot get: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("honeypot hit = %d, want 404", resp.StatusCode) + } + if !bl.Banned("127.0.0.1") { + t.Fatal("a honeypot hit must ban the client IP") + } + follow, err := noRedirect().Get(url + "/") + if err != nil { + t.Fatalf("follow-up get: %v", err) + } + _ = follow.Body.Close() + if follow.StatusCode != http.StatusTooManyRequests { + t.Fatalf("post-trip GET / = %d, want 429", follow.StatusCode) + } +} + +// TestHoneypotDetectsWithoutBanWhenDisabled verifies the prod-only gate: a disabled +// banlist still 404s the decoy (detection/logging) but bans nothing. +func TestHoneypotDetectsWithoutBanWhenDisabled(t *testing.T) { + bl := ratelimit.NewBanlist(ratelimit.BanConfig{}) // disabled + url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil) + req.Header.Set(honeypotHeader, "1") + resp, err := noRedirect().Do(req) + if err != nil { + t.Fatalf("honeypot get: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("decoy = %d, want 404", resp.StatusCode) + } + if bl.Banned("127.0.0.1") { + t.Fatal("a disabled banlist must not ban") + } + follow, err := noRedirect().Get(url + "/") + if err != nil { + t.Fatalf("follow-up: %v", err) + } + _ = follow.Body.Close() + if follow.StatusCode != http.StatusPermanentRedirect { + t.Fatalf("post-trip GET / = %d, want 308 (not banned)", follow.StatusCode) + } +} + +// TestPublicRejectionStrikesBan verifies a public-class limiter rejection feeds the +// banlist: with a one-strike threshold the rejected IP is then banned. +func TestPublicRejectionStrikesBan(t *testing.T) { + bl := enabledBanlist(1) + limits := config.DefaultRateLimit() + limits.PublicPerMinute, limits.PublicBurst = 1, 1 + _, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`)) + }) + defer cleanup() + + if _, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest})); err != nil { + t.Fatalf("first execute: %v", err) + } + _, err := client.Execute(context.Background(), connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest})) + if connect.CodeOf(err) != connect.CodeResourceExhausted { + t.Fatalf("second execute code = %v, want ResourceExhausted", connect.CodeOf(err)) + } + if !bl.Banned("127.0.0.1") { + t.Fatal("a public rejection must strike the banlist") + } +} + +// TestUserRejectionDoesNotBan verifies the user limiter class (keyed by account id, +// not IP) does not feed the IP banlist — that path is the backend's soft flag. +func TestUserRejectionDoesNotBan(t *testing.T) { + bl := enabledBanlist(1) + limits := config.DefaultRateLimit() + limits.UserPerMinute, limits.UserBurst = 1, 1 + _, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/internal/sessions/resolve": + _, _ = w.Write([]byte(`{"user_id":"u-1","is_guest":false}`)) + case "/api/v1/user/feedback/unread": + _, _ = w.Write([]byte(`{"reply_unread":false}`)) + default: + t.Errorf("unexpected backend path %s", r.URL.Path) + } + }) + defer cleanup() + + for i := range 2 { + req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgFeedbackUnread}) + req.Header().Set("Authorization", "Bearer tok") + _, err := client.Execute(context.Background(), req) + if i == 1 && connect.CodeOf(err) != connect.CodeResourceExhausted { + t.Fatalf("second execute code = %v, want ResourceExhausted", connect.CodeOf(err)) + } + } + if len(bl.Active()) != 0 { + t.Fatalf("user-class rejection must not ban; active = %v", bl.Active()) + } +} + +// TestHoneytokenBansAndRejects verifies presenting the planted honeytoken bans the +// caller and returns the ordinary invalid-session error without a backend call. +func TestHoneytokenBansAndRejects(t *testing.T) { + bl := enabledBanlist(100) + _, client, cleanup := guardedEdge(t, bl, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) { + t.Error("backend must not be called for the honeytoken") + }) + defer cleanup() + + req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgProfileGet}) + req.Header().Set("Authorization", "Bearer s3cr3t-trap") + _, err := client.Execute(context.Background(), req) + if connect.CodeOf(err) != connect.CodeUnauthenticated { + t.Fatalf("honeytoken code = %v, want Unauthenticated", connect.CodeOf(err)) + } + if !bl.Banned("127.0.0.1") { + t.Fatal("the honeytoken must ban the caller") + } +} diff --git a/gateway/internal/connectsrv/metrics.go b/gateway/internal/connectsrv/metrics.go index ab18bf1..e74e4ac 100644 --- a/gateway/internal/connectsrv/metrics.go +++ b/gateway/internal/connectsrv/metrics.go @@ -26,6 +26,7 @@ var activeUserWindows = []struct { type serverMetrics struct { edge metric.Float64Histogram rateLimited metric.Int64Counter + banned metric.Int64Counter active *activeUsers } @@ -48,7 +49,12 @@ func newServerMetrics(meter metric.Meter) *serverMetrics { if err != nil { c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_rate_limited_total") } - m := &serverMetrics{edge: h, rateLimited: c, active: newActiveUsers()} + b, err := meter.Int64Counter("gateway_abuse_banned_total", + metric.WithDescription("Temporary IP bans applied at the edge, by reason (rejections, tripwire or honeytoken).")) + if err != nil { + b, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_abuse_banned_total") + } + m := &serverMetrics{edge: h, rateLimited: c, banned: b, active: newActiveUsers()} gauge, err := meter.Int64ObservableGauge("active_users", metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance).")) @@ -86,3 +92,9 @@ func (m *serverMetrics) recordActive(uid string) { func (m *serverMetrics) recordRateLimited(ctx context.Context, class string) { m.rateLimited.Add(ctx, 1, metric.WithAttributes(attribute.String("class", class))) } + +// recordBan counts one temporary IP ban under reason (rejections, tripwire or +// honeytoken). +func (m *serverMetrics) recordBan(ctx context.Context, reason string) { + m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) +} diff --git a/gateway/internal/connectsrv/metrics_test.go b/gateway/internal/connectsrv/metrics_test.go index 2f0910c..9e9f314 100644 --- a/gateway/internal/connectsrv/metrics_test.go +++ b/gateway/internal/connectsrv/metrics_test.go @@ -90,3 +90,41 @@ func TestRateLimitedMetric(t *testing.T) { t.Errorf("rate_limited counts = %v, want user=2 public=1", counts) } } + +// TestBannedMetric records ban events through a manual reader and asserts +// gateway_abuse_banned_total splits by reason. +func TestBannedMetric(t *testing.T) { + ctx := context.Background() + reader := sdkmetric.NewManualReader() + meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") + m := newServerMetrics(meter) + + m.recordBan(ctx, "tripwire") + m.recordBan(ctx, "tripwire") + m.recordBan(ctx, "honeytoken") + + var rm metricdata.ResourceMetrics + if err := reader.Collect(ctx, &rm); err != nil { + t.Fatalf("collect: %v", err) + } + + counts := map[string]int64{} + for _, sm := range rm.ScopeMetrics { + for _, md := range sm.Metrics { + if md.Name != "gateway_abuse_banned_total" { + continue + } + sum, ok := md.Data.(metricdata.Sum[int64]) + if !ok { + t.Fatalf("gateway_abuse_banned_total is not an int64 sum") + } + for _, dp := range sum.DataPoints { + reason, _ := dp.Attributes.Value(attribute.Key("reason")) + counts[reason.AsString()] += dp.Value + } + } + } + if counts["tripwire"] != 2 || counts["honeytoken"] != 1 { + t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts) + } +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index 5796e88..f0665da 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -8,6 +8,7 @@ package connectsrv import ( "context" + "crypto/subtle" "errors" "net" "net/http" @@ -34,6 +35,12 @@ import ( // heartbeatKind is the live-stream keep-alive event kind. const heartbeatKind = "heartbeat" +// honeypotHeader marks a request the edge proxy routed from a honeypot decoy path; +// any request carrying it is treated as a scanner hit. The proxy strips any +// client-supplied value before setting its own, and a spoofed value only bans the +// spoofer, so trusting it is safe. +const honeypotHeader = "X-Scrabble-Honeypot" + // Limiter classes, the `class` attribute of gateway_rate_limited_total and the // class field of the periodic rejection report. const ( @@ -63,6 +70,8 @@ type Server struct { sessions *session.Cache limiter *ratelimit.Limiter tracker *ratelimit.Tracker + banlist *ratelimit.Banlist + honeytoken string hub *push.Hub heartbeat time.Duration log *zap.Logger @@ -86,7 +95,13 @@ type Deps struct { // Tracker accumulates limiter rejections for the periodic report; nil // selects a private tracker (rejections are then only counted, never // reported). - Tracker *ratelimit.Tracker + Tracker *ratelimit.Tracker + // Banlist enforces temporary IP bans on the hot path; nil selects a disabled + // (inert) banlist. + Banlist *ratelimit.Banlist + // Honeytoken, when non-empty, is the planted bearer value whose presentation + // bans the caller and raises a high-severity alarm. + Honeytoken string Hub *push.Hub RateLimit config.RateLimitConfig Heartbeat time.Duration @@ -116,6 +131,10 @@ func NewServer(d Deps) *Server { if limiter == nil { limiter = ratelimit.New() } + banlist := d.Banlist + if banlist == nil { + banlist = ratelimit.NewBanlist(ratelimit.BanConfig{}) + } rl := d.RateLimit if rl == (config.RateLimitConfig{}) { rl = config.DefaultRateLimit() @@ -125,6 +144,8 @@ func NewServer(d Deps) *Server { sessions: d.Sessions, limiter: limiter, tracker: tracker, + banlist: banlist, + honeytoken: d.Honeytoken, hub: d.Hub, heartbeat: d.Heartbeat, log: log, @@ -172,9 +193,11 @@ func (s *Server) HTTPHandler() http.Handler { mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html")) mux.Handle("/app/", webui.Handler("/app/", "index.html")) mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect)) - // Every request body on the public listener is capped (the admin proxy POSTs - // included); the h2c server carries explicit stream/idle sizing. - return h2c.NewHandler(maxBodyHandler(s.maxBodyBytes, mux), &http2.Server{ + // abuseGuard is the outermost wrap (right under h2c) so a banned IP or a + // honeypot hit is turned away before the body cap and the mux. Every request + // body on the public listener is then capped (the admin proxy POSTs included); + // the h2c server carries explicit stream/idle sizing. + return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{ MaxConcurrentStreams: h2cMaxConcurrentStreams, IdleTimeout: h2cIdleTimeout, }) @@ -189,6 +212,32 @@ func maxBodyHandler(limit int, next http.Handler) http.Handler { }) } +// abuseGuard refuses a banned client IP with 429 before any work, and turns a +// honeypot decoy hit (the proxy-set honeypotHeader) into an instant ban plus a +// bland 404 that is indistinguishable from an ordinary miss. The ban check and the +// tripwire ban are inert on a disabled banlist (the prod-only gate); the tripwire +// hit is logged either way as scanner telemetry. +func (s *Server) abuseGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := peerIP(r.RemoteAddr, r.Header) + if s.banlist.Banned(ip) { + http.Error(w, "banned", http.StatusTooManyRequests) + return + } + if r.Header.Get(honeypotHeader) != "" { + s.log.Warn("honeypot tripwire", + zap.String("path", r.URL.Path), + zap.String("client_ip", ip)) + if s.banlist.BanNow(ip, ratelimit.ReasonTripwire) { + s.metrics.recordBan(r.Context(), string(ratelimit.ReasonTripwire)) + } + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) +} + // Execute runs one unary operation. Domain failures are returned in the envelope // (result_code != "ok", HTTP 200); only edge failures (rate limit, missing // session, unknown type, internal) become Connect errors. @@ -207,7 +256,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP} if op.Auth { - uid, isGuest, err := s.resolve(ctx, req.Header()) + uid, isGuest, err := s.resolve(ctx, req.Header(), clientIP) if err != nil { result = "unauthenticated" return nil, err @@ -263,7 +312,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut // Subscribe streams the authenticated user's live events with a keep-alive // heartbeat until the client disconnects. func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error { - uid, _, err := s.resolve(ctx, req.Header()) + uid, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header())) if err != nil { return err } @@ -311,6 +360,12 @@ func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.Subs func (s *Server) noteRateLimited(ctx context.Context, class, key, msgType string) { s.metrics.recordRateLimited(ctx, class) s.tracker.Add(class, key) + // IP-keyed rejections (public, email, admin — the key is the client IP) feed + // the ban; the user class is keyed by account id and is the backend soft-flag's + // concern, not the IP ban's. + if class != classUser && s.banlist.Strike(key) { + s.metrics.recordBan(ctx, string(ratelimit.ReasonRejections)) + } s.log.Debug("rate limited", zap.String("class", class), zap.String("key", key), @@ -344,11 +399,21 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler { // resolve extracts and resolves the Authorization bearer token to an account id // and its guest flag, returning a Connect Unauthenticated error when it is missing // or unknown. -func (s *Server) resolve(ctx context.Context, h http.Header) (string, bool, error) { +func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, error) { token := bearerToken(h.Get("Authorization")) if token == "" { return "", false, connect.NewError(connect.CodeUnauthenticated, errMissingToken) } + // The honeytoken is a planted value no real client holds: presenting it is a + // high-confidence intrusion signal, so ban the caller and raise the alarm, then + // return the ordinary invalid-session error so the trap stays indistinguishable. + if s.honeytoken != "" && subtle.ConstantTimeCompare([]byte(token), []byte(s.honeytoken)) == 1 { + s.log.Warn("honeytoken presented", zap.String("client_ip", clientIP)) + if s.banlist.BanNow(clientIP, ratelimit.ReasonHoneytoken) { + s.metrics.recordBan(ctx, string(ratelimit.ReasonHoneytoken)) + } + return "", false, connect.NewError(connect.CodeUnauthenticated, errInvalidSession) + } uid, isGuest, err := s.sessions.Resolve(ctx, token) if err != nil { // An unknown or expired token (a backend 4xx) is the client's problem and diff --git a/gateway/internal/ratelimit/banlist.go b/gateway/internal/ratelimit/banlist.go new file mode 100644 index 0000000..a67ddb2 --- /dev/null +++ b/gateway/internal/ratelimit/banlist.go @@ -0,0 +1,211 @@ +package ratelimit + +import ( + "sort" + "sync" + "time" +) + +// Reason labels why a client IP was banned; it is the reason attribute of the +// gateway_abuse_banned_total metric and a field of the ban report to the backend. +type Reason string + +const ( + // ReasonRejections is a ban earned by sustained rate-limiter rejections: the IP + // accumulated BanConfig.Threshold strikes within BanConfig.Window. + ReasonRejections Reason = "rejections" + // ReasonTripwire is an instant ban from a honeypot decoy-path hit. + ReasonTripwire Reason = "tripwire" + // ReasonHoneytoken is an instant ban from a planted credential being presented. + ReasonHoneytoken Reason = "honeytoken" +) + +// Ban durations for the high-confidence reasons. A rejection ban uses the +// configured BanConfig.Duration; a tripwire or honeytoken hit is near +// zero-false-positive, so it earns a markedly longer ban. +const ( + tripwireBanDuration = time.Hour + honeytokenBanDuration = 24 * time.Hour +) + +// BanConfig tunes the temporary IP ban. Enabled gates the whole mechanism — kept +// off where the real client IP is not visible (e.g. every client arriving as one +// shared NAT address); when false every method is inert. +type BanConfig struct { + // Enabled turns enforcement on. While false Strike/BanNow record nothing, + // Banned is always false and Active is empty. + Enabled bool + // Threshold is the strike count within Window that earns a rejection ban. + Threshold int + // Window is the rolling window the strikes accumulate over. + Window time.Duration + // Duration is the length of a rejection ban (tripwire/honeytoken use their own). + Duration time.Duration +} + +// Ban is a snapshot of one active ban for the periodic report and the admin view. +// Its JSON shape is the gateway→backend ban-sync wire contract. +type Ban struct { + IP string `json:"ip"` + Reason Reason `json:"reason"` + Since time.Time `json:"since"` + Expires time.Time `json:"expires"` +} + +// Banlist is the gateway's in-memory temporary IP ban: a fail2ban-style block fed +// by sustained rate-limiter rejections (Strike) and by instant honeypot / +// honeytoken hits (BanNow), enforced on the hot path by Banned and cleared either +// by lapse or by an operator Unban. Entries are swept lazily so an expired ban +// does not leak memory. Like the rate limiter it is single-instance and resets on +// restart by design. +type Banlist struct { + cfg BanConfig + now func() time.Time + + mu sync.Mutex + entries map[string]*banEntry + lastSweep time.Time +} + +// banEntry is one IP's ban state: a live ban (until set) or, before the +// threshold, only the recent strike times. +type banEntry struct { + reason Reason + since time.Time + until time.Time // zero while only accumulating strikes + strikes []time.Time // strike times within the window; pruned on each strike +} + +// NewBanlist constructs a Banlist with cfg. +func NewBanlist(cfg BanConfig) *Banlist { + return &Banlist{cfg: cfg, now: time.Now, entries: make(map[string]*banEntry)} +} + +// Strike records one rate-limiter rejection for ip and reports whether it earned a +// ban (Threshold strikes within Window). It is a no-op on a disabled banlist. +func (b *Banlist) Strike(ip string) bool { + if !b.cfg.Enabled { + return false + } + b.mu.Lock() + defer b.mu.Unlock() + now := b.now() + b.sweepLocked(now) + e := b.entries[ip] + if e == nil { + e = &banEntry{} + b.entries[ip] = e + } + if now.Before(e.until) { + return false // already banned (a banned IP normally never reaches here) + } + cutoff := now.Add(-b.cfg.Window) + kept := e.strikes[:0] + for _, t := range e.strikes { + if t.After(cutoff) { + kept = append(kept, t) + } + } + e.strikes = append(kept, now) + if len(e.strikes) >= b.cfg.Threshold { + e.reason = ReasonRejections + e.since = now + e.until = now.Add(b.cfg.Duration) + e.strikes = nil + return true + } + return false +} + +// BanNow bans ip immediately for reason and reports whether a ban is now in effect +// (true on an enabled banlist, false when disabled). +func (b *Banlist) BanNow(ip string, reason Reason) bool { + if !b.cfg.Enabled { + return false + } + b.mu.Lock() + defer b.mu.Unlock() + now := b.now() + b.sweepLocked(now) + e := b.entries[ip] + if e == nil { + e = &banEntry{} + b.entries[ip] = e + } + e.reason = reason + e.since = now + e.until = now.Add(banDuration(reason, b.cfg.Duration)) + e.strikes = nil + return true +} + +// Banned reports whether ip is currently banned. It is always false on a disabled +// banlist. +func (b *Banlist) Banned(ip string) bool { + if !b.cfg.Enabled { + return false + } + b.mu.Lock() + defer b.mu.Unlock() + e := b.entries[ip] + return e != nil && b.now().Before(e.until) +} + +// Unban clears any ban and accumulated strikes for ip. +func (b *Banlist) Unban(ip string) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.entries, ip) +} + +// Active returns a snapshot of the currently-banned IPs, most recently banned +// first. It is empty on a disabled banlist. +func (b *Banlist) Active() []Ban { + if !b.cfg.Enabled { + return nil + } + b.mu.Lock() + defer b.mu.Unlock() + now := b.now() + out := make([]Ban, 0, len(b.entries)) + for ip, e := range b.entries { + if now.Before(e.until) { + out = append(out, Ban{IP: ip, Reason: e.reason, Since: e.since, Expires: e.until}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Since.After(out[j].Since) }) + return out +} + +// banDuration maps a reason to its ban length. +func banDuration(reason Reason, rejectionDuration time.Duration) time.Duration { + switch reason { + case ReasonTripwire: + return tripwireBanDuration + case ReasonHoneytoken: + return honeytokenBanDuration + default: + return rejectionDuration + } +} + +// sweepLocked discards lapsed bans and stale strike-only entries, at most once per +// sweepInterval. The caller holds b.mu. +func (b *Banlist) sweepLocked(now time.Time) { + if now.Sub(b.lastSweep) < sweepInterval { + return + } + b.lastSweep = now + cutoff := now.Add(-b.cfg.Window) + for ip, e := range b.entries { + if !e.until.IsZero() { + if !now.Before(e.until) { + delete(b.entries, ip) // ban lapsed + } + continue + } + if len(e.strikes) == 0 || !e.strikes[len(e.strikes)-1].After(cutoff) { + delete(b.entries, ip) // strike-only entry gone stale + } + } +} diff --git a/gateway/internal/ratelimit/banlist_test.go b/gateway/internal/ratelimit/banlist_test.go new file mode 100644 index 0000000..126ec32 --- /dev/null +++ b/gateway/internal/ratelimit/banlist_test.go @@ -0,0 +1,143 @@ +package ratelimit + +import ( + "testing" + "time" +) + +// banlistAt builds an enabled banlist whose clock the test drives through clk. +func banlistAt(clk *time.Time, cfg BanConfig) *Banlist { + bl := NewBanlist(cfg) + bl.now = func() time.Time { return *clk } + return bl +} + +func enabledCfg() BanConfig { + return BanConfig{Enabled: true, Threshold: 3, Window: time.Minute, Duration: 15 * time.Minute} +} + +func TestBanlistStrikeThreshold(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + bl := banlistAt(&clk, enabledCfg()) + + if bl.Strike("1.2.3.4") { + t.Fatal("first strike must not ban") + } + if bl.Strike("1.2.3.4") { + t.Fatal("second strike must not ban") + } + if bl.Banned("1.2.3.4") { + t.Fatal("must not be banned before the third strike") + } + if !bl.Strike("1.2.3.4") { + t.Fatal("third strike within the window must ban") + } + if !bl.Banned("1.2.3.4") { + t.Fatal("must be banned after the threshold strike") + } +} + +func TestBanlistStrikeWindowResets(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + bl := banlistAt(&clk, enabledCfg()) + + // Strikes spaced wider than the window never accumulate to a ban. + for i := range 5 { + if bl.Strike("9.9.9.9") { + t.Fatalf("strike %d should not ban: each falls outside the previous window", i) + } + clk = clk.Add(2 * time.Minute) + } + if bl.Banned("9.9.9.9") { + t.Fatal("strikes outside the rolling window must not ban") + } +} + +func TestBanlistBanExpires(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + bl := banlistAt(&clk, enabledCfg()) + + bl.BanNow("5.5.5.5", ReasonRejections) + if !bl.Banned("5.5.5.5") { + t.Fatal("must be banned right after BanNow") + } + clk = clk.Add(15*time.Minute + time.Second) + if bl.Banned("5.5.5.5") { + t.Fatal("ban must lapse after its duration") + } +} + +func TestBanlistReasonDurations(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + bl := banlistAt(&clk, enabledCfg()) + + bl.BanNow("a", ReasonTripwire) // 1h + bl.BanNow("b", ReasonHoneytoken) // 24h + clk = clk.Add(90 * time.Minute) + if bl.Banned("a") { + t.Fatal("tripwire ban (1h) must have lapsed after 90m") + } + if !bl.Banned("b") { + t.Fatal("honeytoken ban (24h) must still hold after 90m") + } +} + +func TestBanlistUnban(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + bl := banlistAt(&clk, enabledCfg()) + + bl.BanNow("7.7.7.7", ReasonTripwire) + bl.Unban("7.7.7.7") + if bl.Banned("7.7.7.7") { + t.Fatal("Unban must clear the ban") + } +} + +func TestBanlistActiveSnapshot(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + bl := banlistAt(&clk, enabledCfg()) + + bl.BanNow("a", ReasonTripwire) + bl.BanNow("b", ReasonHoneytoken) + active := bl.Active() + if len(active) != 2 { + t.Fatalf("Active = %d bans, want 2", len(active)) + } + byIP := map[string]Ban{} + for _, b := range active { + byIP[b.IP] = b + } + if byIP["a"].Reason != ReasonTripwire || byIP["b"].Reason != ReasonHoneytoken { + t.Fatalf("Active reasons = %+v", byIP) + } + if !byIP["a"].Expires.After(byIP["a"].Since) { + t.Fatal("Expires must be after Since") + } + + clk = clk.Add(2 * time.Hour) // tripwire (1h) lapses, honeytoken (24h) holds + if got := len(bl.Active()); got != 1 { + t.Fatalf("Active after 2h = %d, want 1 (only honeytoken)", got) + } +} + +func TestBanlistDisabledIsInert(t *testing.T) { + clk := time.Date(2026, 6, 21, 12, 0, 0, 0, time.UTC) + cfg := enabledCfg() + cfg.Enabled = false + bl := banlistAt(&clk, cfg) + + for range 10 { + if bl.Strike("1.1.1.1") { + t.Fatal("disabled banlist must never ban via Strike") + } + } + if bl.BanNow("2.2.2.2", ReasonHoneytoken) { + t.Fatal("disabled banlist must never ban via BanNow") + } + if bl.Banned("1.1.1.1") || bl.Banned("2.2.2.2") { + t.Fatal("disabled banlist must report nothing banned") + } + if len(bl.Active()) != 0 { + t.Fatal("disabled banlist must expose no active bans") + } +} -- 2.52.0 From 81b44c2b02791222689c360577e6a94b50e579d0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 08:58:10 +0200 Subject: [PATCH 2/3] docs(prerelease): mark phase AG done (code + test contour) --- PRERELEASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PRERELEASE.md b/PRERELEASE.md index 20ed405..718f609 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -40,7 +40,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** | | DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** | | TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) | -| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **in progress** | +| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban enabled in prod → Stage 18) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) -- 2.52.0 From cf9fa75d625a9dd821315cfccf1278fc2038b4f1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 21 Jun 2026 09:02:23 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(deploy):=20honeypot=20tag=20dropped=20?= =?UTF-8?q?=E2=80=94=20Caddy=20applies=20header=5Fup=20delete=20after=20se?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @honeypot block both deleted and set X-Scrabble-Honeypot in one reverse_proxy. Caddy applies header_up deletions *after* sets, so the tag we set was immediately stripped: the gateway never saw it, and a decoy hit (e.g. GET /.env) fell through to the gateway's /app redirect (308) instead of tripping the honeypot. Drop the delete — the bare set already replaces any client-supplied value. The real endpoints keep stripping the header in the @gateway block (delete-only, no conflicting set). Caught on the live test contour (no caddy locally). --- deploy/caddy/Caddyfile | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 80189f2..051940a 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -49,14 +49,15 @@ } # Honeypot decoy paths: classic vulnerability-scanner bait no real client ever - # requests. Route them to the gateway tagged with X-Scrabble-Honeypot so it logs - # the scanner hit and (in prod) bans the source IP. The inbound header is - # stripped first so a client cannot pre-set it — and even if it did, it would - # only ban itself. Keep this list in sync with no legitimate landing/app path. + # requests. Route them to the gateway tagged with X-Scrabble-Honeypot — the set + # replaces any client-supplied value — so it logs the scanner hit and (in prod) + # bans the source IP. (A delete + set in one block would not work: Caddy applies + # header_up deletions after sets, which would strip the tag we just set; the real + # endpoints instead strip the header in the @gateway block above.) Keep this list + # disjoint from every legitimate landing/app path. @honeypot path /.env /.git /.git/* /.aws/* /wp-login.php /wp-admin /wp-admin/* /phpmyadmin /phpmyadmin/* handle @honeypot { reverse_proxy gateway:8081 { - header_up -X-Scrabble-Honeypot header_up X-Scrabble-Honeypot 1 } } -- 2.52.0
ClassKeyRejectedFirst seenLast seen