check / check (push) In progress
Rename too_large, rate_limited, rule_blocked and denied, and split banned into temporarily_banned and permanently_banned by whether the ban runs out. A client under both a temporary and a permanent ban gets the permanent one. A request that makes a ban permanent gets permanently_banned. A ban rule, a trap path or a CrowdSec decision gets the value for the ban it makes, and in observe mode would_action names the ban enforce mode would have made. The offence kind rule_blocked follows, as a metric label and a key in clients.json. Model: opus-5-5
241 lines
6.6 KiB
Go
241 lines
6.6 KiB
Go
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_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_DENY_NETS
|
|
banned = otherClient // under a ban read from bans.json
|
|
)
|
|
|
|
for _, tc := range []struct {
|
|
setting string // "" leaves SWW_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)
|
|
}
|
|
}
|