041106d623
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.
85 lines
2.9 KiB
Go
85 lines
2.9 KiB
Go
package backendclient_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"scrabble/gateway/internal/backendclient"
|
|
"scrabble/gateway/internal/ratelimit"
|
|
)
|
|
|
|
// TestReportRateLimited verifies the rejection report reaches the backend's
|
|
// internal endpoint with the agreed JSON shape and no user identity.
|
|
func TestReportRateLimited(t *testing.T) {
|
|
var got struct {
|
|
WindowSeconds int `json:"window_seconds"`
|
|
Entries []ratelimit.Rejection `json:"entries"`
|
|
}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/internal/ratelimit/report" {
|
|
t.Errorf("call = %s %s, want POST /api/v1/internal/ratelimit/report", r.Method, r.URL.Path)
|
|
}
|
|
if uid := r.Header.Get("X-User-ID"); uid != "" {
|
|
t.Errorf("X-User-ID = %q, want empty", uid)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
|
t.Errorf("decode report: %v", err)
|
|
}
|
|
}))
|
|
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() }()
|
|
|
|
entries := []ratelimit.Rejection{{Class: "user", Key: "u-1", Rejected: 5}}
|
|
if err := c.ReportRateLimited(context.Background(), 30, entries); err != nil {
|
|
t.Fatalf("ReportRateLimited: %v", err)
|
|
}
|
|
if got.WindowSeconds != 30 || len(got.Entries) != 1 || got.Entries[0] != entries[0] {
|
|
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)
|
|
}
|
|
}
|