check / check (push) Failing after 1m14s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
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_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)
|
|
}
|
|
}
|