package proxy_test import ( "maps" "net/http" "net/netip" "reflect" "testing" "time" "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/proxy" "sneak.berlin/go/smallwebwaf/internal/requestlog" ) const ( alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL" // alertInstance is the instance every alert of these tests gives. alertInstance = "fsn1app1/gitea" ) func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) { t.Parallel() s, clk, server, queue := startWithAlerts(t, map[string]string{ rateLimitPerMinute: "1", banScopeV4Prefix: "24", }) start := clk.Now() s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) netblock := netip.MustParsePrefix("203.0.113.0/24") ban := server.Ledger.Bans(netblock)[0] // A request refused under the ban raises no other alert. clk.advance(time.Minute) s.get(client, http.StatusForbidden, requestlog.ActionBanned) wantAlerts(t, queue, alerts.Alert{ Instance: alertInstance, Time: start, Event: alerts.EventBan, Client: netip.MustParseAddr(client), Netblock: netblock, Reason: "requests per minute over the limit of 1", Detail: map[string]any{ "cause": bans.CauseLimit, "ban_expires": requestlog.FormatTime(start.Add(time.Hour)), "notes": ban.Notes, }, }) if ban.Notes.Limit != 1 || ban.Notes.Request.Path != "/" { t.Errorf("the alert's notes are %+v, want those of the broken limit", ban.Notes) } } func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) { t.Parallel() s, clk, server, queue := startWithAlerts(t, map[string]string{ rulesDir: writeRules(t, testRules), }) start := clk.Now() netblock := netip.MustParsePrefix(client + "/32") other := netip.MustParsePrefix(otherClient + "/32") // The probe bans the client for seven days, and its next request makes // the ban permanent. The request after that changes nothing. s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) attackBan := server.Ledger.Bans(netblock)[0] clk.advance(time.Minute) s.get(client, http.StatusForbidden, requestlog.ActionBanned) permanentBan := server.Ledger.Bans(netblock)[0] s.get(client, http.StatusForbidden, requestlog.ActionBanned) // Another client's probe after its first ban has run out without a // request makes a permanent ban at once. s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned) clk.advance(7 * 24 * time.Hour) s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned) otherBans := server.Ledger.Bans(other) wantAlerts(t, queue, attackAlert(alerts.EventBan, start, client, attackBan, requestlog.FormatTime(start.Add(7*24*time.Hour))), attackAlert(alerts.EventPermanentBan, start.Add(time.Minute), client, permanentBan, "permanent"), attackAlert(alerts.EventBan, start.Add(time.Minute), otherClient, otherBans[0], requestlog.FormatTime(start.Add(time.Minute+7*24*time.Hour))), attackAlert(alerts.EventPermanentBan, start.Add(time.Minute+7*24*time.Hour), otherClient, otherBans[1], "permanent"), ) } func TestObserveModeRaisesNoBanAlert(t *testing.T) { t.Parallel() s, _, _, queue := startWithAlerts(t, map[string]string{ mode: "observe", rateLimitPerMinute: "1", rulesDir: writeRules(t, testRules), }) s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward) s.request(otherClient, "/.env", http.StatusOK, requestlog.ActionForward) wantAlerts(t, queue) } // startWithAlerts is startWithClock with alerts to a webhook, which is // never sent them, and returns the queue they wait in as well. func startWithAlerts( t *testing.T, env map[string]string, ) (*sender, *clock, *proxy.Server, *alerts.Queue) { t.Helper() app := startApp(t, func(http.ResponseWriter, *http.Request) {}) clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)} settings := map[string]string{ trustedProxies: trustLocalhost, alertWebhookURL: "https://alerts.example/smallwebwaf", instanceName: alertInstance, } maps.Copy(settings, env) addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings) return &sender{t: t, addr: addr, out: out}, clk, server, queue } // attackAlert returns the alert for event, raised by a request from client // at the time raised, for ban, a ban for the probe rule of testRules, // which ends at expires, as the log line gives it. func attackAlert( event string, raised time.Time, client string, ban bans.Ban, expires string, ) alerts.Alert { return alerts.Alert{ Instance: alertInstance, Time: raised, Event: event, Client: netip.MustParseAddr(client), Netblock: ban.Netblock, Reason: "matched the rule probe", Detail: map[string]any{ "cause": bans.CauseAttack, "ban_expires": expires, "notes": ban.Notes, }, } } // wantAlerts checks the alerts waiting in queue, in order. func wantAlerts(t *testing.T, queue *alerts.Queue, want ...alerts.Alert) { t.Helper() got := queue.Snapshot().Waiting if len(got) != len(want) { t.Fatalf("%d alerts wait, want %d: %+v", len(got), len(want), got) } for i := range want { if !reflect.DeepEqual(got[i], want[i]) { t.Errorf("alert %d is\n%+v\nwant\n%+v", i, got[i], want[i]) } } }