package discriminator_test import ( "net/http" "net/netip" "reflect" "testing" "time" "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/requestlog" ) // observe is the value of SWW_WAF_MODE for observe mode. const observe = "observe" func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) { t.Parallel() const trapPath = "/wp-login.php" for _, tc := range []struct { name string cause string // endsIn is how long after the request the ban ends: negative for a // ban that has ended, and zero for a permanent ban. endsIn time.Duration path string want string }{ { "temporary ban", bans.CauseAdmin, time.Hour, "/", requestlog.ActionTemporarilyBanned, }, {"permanent ban", bans.CauseAdmin, 0, "/", requestlog.ActionPermanentlyBanned}, { "request that makes a ban permanent", bans.CauseAttack, time.Hour, "/", requestlog.ActionPermanentlyBanned, }, { "trap path after an attack ban ended", bans.CauseAttack, -time.Hour, trapPath, requestlog.ActionPermanentlyBanned, }, { "first trap path after another ban ended", bans.CauseAdmin, -time.Hour, trapPath, requestlog.ActionTemporarilyBanned, }, } { for _, modeValue := range []string{"enforce", observe} { t.Run(tc.name+" in "+modeValue, func(t *testing.T) { t.Parallel() s, clk, server := startWithClock(t, "", map[string]string{ trapPaths: trapPathList, mode: modeValue, }) ban := bans.Ban{ Netblock: netip.MustParsePrefix(client + "/32"), Start: clk.Now().Add(-2 * time.Hour), Cause: tc.cause, } if tc.endsIn != 0 { ban.Expires = clk.Now().Add(tc.endsIn) } server.Ledger.Load([]bans.Ban{ban}) if modeValue == observe { line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward) wantWouldAction(t, line, tc.want) return } s.request(client, tc.path, http.StatusForbidden, tc.want) }) } } } func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) { t.Parallel() s, clk, server, queue := startWithAlerts(t, map[string]string{ trapPaths: trapPathList, mode: observe, }) server.Ledger.Load([]bans.Ban{{ Netblock: netip.MustParsePrefix(client + "/32"), Start: clk.Now().Add(-2 * time.Hour), Expires: clk.Now().Add(-time.Hour), Cause: bans.CauseAttack, }}) line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward) wantWouldAction(t, line, requestlog.ActionPermanentlyBanned) waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != 1 || waiting[0].Event != alerts.EventPermanentBan { t.Errorf("alerts waiting are %+v, want one %s", waiting, alerts.EventPermanentBan) } } func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) { t.Parallel() s, _, _ := startWithClock(t, "", map[string]string{ mode: observe, denyNets: client, }) line := s.get(client, http.StatusOK, requestlog.ActionForward) wantWouldAction(t, line, requestlog.ActionClientDenied) wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`) } func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) { t.Parallel() const ( denied = "192.0.2.50" // in SWW_WAF_DENY_NETS banned = otherClient // under a ban read from bans.json ) for _, tc := range []struct { setting string // "" leaves SWW_WAF_MODE at its default observe bool }{ {"", false}, {"enforce", false}, {observe, true}, } { t.Run(mode+"="+tc.setting, func(t *testing.T) { t.Parallel() geojsURL, _ := startGeoJS(t) env := map[string]string{ lookupTimeout: "1h", rateLimitPerMinute: "1", denyNets: denied, tagDeny: denyKP, } if tc.setting != "" { env[mode] = tc.setting } s, clk, server := startWithClock(t, geojsURL, env) server.Ledger.Load([]bans.Ban{{ Netblock: netip.MustParsePrefix(banned + "/32"), Start: clk.Now(), Expires: clk.Now().Add(time.Hour), }}) // fromDE's first request is within the limit of one a minute, // and its second breaks it. s.get(fromDE, http.StatusOK, requestlog.ActionForward) for _, sent := range []struct{ from, refusal string }{ {denied, requestlog.ActionClientDenied}, {banned, requestlog.ActionTemporarilyBanned}, {fromKP, requestlog.ActionClientDenied}, {fromDE, requestlog.ActionClientIsRateLimited}, } { if !tc.observe { line := s.get(sent.from, http.StatusForbidden, sent.refusal) wantWouldAction(t, line, "") continue } // Passed to the app, which answered it. line := s.get(sent.from, http.StatusOK, requestlog.ActionForward) wantWouldAction(t, line, sent.refusal) } }) } } func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) { t.Parallel() s, clk, server := startWithClock(t, "", map[string]string{ mode: observe, rateLimitPerMinute: "1", }) kept := bans.Ban{ Netblock: netip.MustParsePrefix(otherClient + "/32"), Start: clk.Now(), Expires: clk.Now().Add(time.Hour), Cause: bans.CauseAdmin, } server.Ledger.Load([]bans.Ban{kept}) // No ban sets client's counters back to zero, so each request after // the first breaks the limit of one a minute. s.get(client, http.StatusOK, requestlog.ActionForward) for range 2 { line := s.get(client, http.StatusOK, requestlog.ActionForward) wantWouldAction(t, line, requestlog.ActionClientIsRateLimited) if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit || line.BanExpires != "" { t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+ "want minute, limit and none", line.LimitHit, line.Offence, line.BanExpires) } } // The ban read from bans.json refuses nothing, and so counts no // refusal in its notes, but is kept. line := s.get(otherClient, http.StatusOK, requestlog.ActionForward) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned) if line.BanExpires != requestlog.FormatTime(kept.Expires) { t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, requestlog.FormatTime(kept.Expires)) } got := server.Ledger.Snapshot() if len(got) != 1 || !reflect.DeepEqual(got[0], kept) { t.Errorf("bans\n%+v\nwant only\n%+v", got, kept) } } // wantWouldAction checks the request log line's would_action, and that a // line that should have none has no such field. func wantWouldAction(t *testing.T, line logLine, want string) { t.Helper() got, present := line.fields["would_action"] switch { case want == "" && present: t.Errorf("log line has would_action %v, want none", got) case want != "" && got != want: t.Errorf("log line has would_action %v, want %s", got, want) } }