check / check (push) Waiting to run
Each check servewaf runs on a request is a type in its own file behind one interface, heuristic, and is registered in one of four ordered lists in heuristics.go. Order and behaviour are unchanged. Every refusal now logs heuristic, the name of the heuristic that made it, and heuristic_figures, the figures it acted on. A ban by refusal_limit or byte_limits after the answer logs report_heuristic and report_heuristic_figures instead, so a line can name both. All four are in docs/request-log.md, and SPEC.md names each heuristic at its step. Model: opus-5-5
153 lines
4.1 KiB
Go
153 lines
4.1 KiB
Go
package servewaf_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/netip"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// observe is the value of SWWAF_MODE for observe mode.
|
|
const observe = "observe"
|
|
|
|
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.ActionDenied)
|
|
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
|
|
}
|
|
|
|
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
|
banned = otherClient // under a ban read from bans.json
|
|
)
|
|
|
|
for _, tc := range []struct {
|
|
setting string // "" leaves SWWAF_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,
|
|
deniedCountries: "kp",
|
|
}
|
|
|
|
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.ActionDenied},
|
|
{banned, requestlog.ActionBanned},
|
|
{fromKP, requestlog.ActionCountryDenied},
|
|
{fromDE, requestlog.ActionRateLimited},
|
|
} {
|
|
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.ActionRateLimited)
|
|
|
|
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.ActionBanned)
|
|
|
|
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)
|
|
}
|
|
}
|