Format Go with gofmt, then golines -m 77 (closes #215)
check / check (push) Waiting to run

script/fmt now runs golines -m 77 after gofmt, repeating golines until
it changes nothing, since one pass can leave a line the next splits.
script/fmt-check fails when either would change a file.
script/bootstrap installs golines v0.13.0 with go install, pinned to
its commit, and checks the installed version with go version -m.

The tree is reformatted. The longer lines this makes are fixed by
hand: nolint directives moved onto their own line above the statement,
one string split, and 20 functions over funlen's 80 lines shortened or
split, RunWAF and limitPercentages among them.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-11 15:14:58 +00:00
1 parent 63e58e326b
commit 7f8a59de04
118 files changed
+9447 -2670

No files matched your search

+12 -3
View File
@@ -26,7 +26,11 @@ type abuseIPDB struct{}
func (abuseIPDB) name() string { return "abuseipdb" }
func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
func (abuseIPDB) check(
ctx context.Context,
rq *request,
_ time.Time,
) *verdict {
if rq.h.config.AbuseIPDBKey == "" {
return nil
}
@@ -42,8 +46,13 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
rq.abuseIPDBHit = true
rq.addTag(config.AbuseIPDBTag)
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
rq.noteHit(
bans.ReputationHit{
Source: reputation.AbuseIPDBSource,
Score: &score,
},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE",
)
if rq.h.config.ReputationAction != deny {
return nil
+47 -11
View File
@@ -31,7 +31,8 @@ var (
errNotBanToAdd = errors.New(
"the body is not a JSON object of netblock, duration and reason")
errNotNetblock = errors.New(
"is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24")
"is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24",
)
errMappedNetblock = errors.New(
"is IPv4-mapped: give the IPv4 netblock, such as 203.0.113.0/24")
errZone = errors.New("has a zone, which a netblock cannot have")
@@ -61,10 +62,18 @@ func (rq *request) answerAdmin() {
switch {
case token == "":
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
http.Error(
rq.out,
http.StatusText(http.StatusNotFound),
http.StatusNotFound,
)
case !hasToken(rq.in, token):
rq.out.Header().Set("WWW-Authenticate", "Bearer")
http.Error(rq.out, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
http.Error(
rq.out,
http.StatusText(http.StatusUnauthorized),
http.StatusUnauthorized,
)
default:
answer()
}
@@ -150,7 +159,9 @@ func (rq *request) addBan() {
// readBanToAdd reads the body of POST BansPath: a JSON object with
// nothing but whitespace after it, in at most banBodyMaxBytes.
func (rq *request) readBanToAdd() (banToAdd, error) {
data, err := io.ReadAll(http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes))
data, err := io.ReadAll(
http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes),
)
rq.requestBytes = int64(len(data))
if err != nil {
@@ -170,7 +181,10 @@ func (rq *request) readBanToAdd() (banToAdd, error) {
// Token returns io.EOF only when nothing but whitespace is left.
_, err = decoder.Token()
if !errors.Is(err, io.EOF) {
return banToAdd{}, fmt.Errorf("%w: more follows the object", errNotBanToAdd)
return banToAdd{}, fmt.Errorf(
"%w: more follows the object",
errNotBanToAdd,
)
}
return toAdd, nil
@@ -185,7 +199,11 @@ func (h *handler) banNetblock(value string) (netip.Prefix, error) {
netblock, err := netip.ParsePrefix(value)
if err == nil {
if netblock.Addr().Is4In6() {
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errMappedNetblock)
return netip.Prefix{}, fmt.Errorf(
"netblock %q %w",
value,
errMappedNetblock,
)
}
return netblock, nil
@@ -195,7 +213,11 @@ func (h *handler) banNetblock(value string) (netip.Prefix, error) {
// 2001:db8::1%x/48 as part of the zone.
addr, err := netip.ParseAddr(value)
if err != nil {
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errNotNetblock)
return netip.Prefix{}, fmt.Errorf(
"netblock %q %w",
value,
errNotNetblock,
)
}
if addr.Zone() != "" {
@@ -214,7 +236,11 @@ func expiry(duration string, now time.Time) (time.Time, error) {
length, err := config.ParseDurationNotOff(duration)
if err != nil {
return time.Time{}, fmt.Errorf("duration %q %w", duration, errNotDuration)
return time.Time{}, fmt.Errorf(
"duration %q %w",
duration,
errNotDuration,
)
}
return now.Add(length), nil
@@ -233,7 +259,11 @@ func (rq *request) liftBans() {
lifted := rq.h.ledger.Lift(client, rq.h.now())
if len(lifted) == 0 {
http.Error(rq.out, "no ban is active on "+client.String(), http.StatusNotFound)
http.Error(
rq.out,
"no ban is active on "+client.String(),
http.StatusNotFound,
)
return
}
@@ -261,7 +291,9 @@ func (rq *request) showClient() {
return
}
answer := clientAnswer{Bans: state.BanEntries(rq.h.ledger.Covering(addr))}
answer := clientAnswer{
Bans: state.BanEntries(rq.h.ledger.Covering(addr)),
}
client, seen := rq.h.limiter.Client(rq.h.clientGroup(addr))
if seen {
@@ -294,7 +326,11 @@ func (rq *request) answerBans(held []bans.Ban) {
func (rq *request) answerJSON(value any) {
body, err := json.MarshalIndent(value, "", " ")
if err != nil {
rq.h.processLog.Error("encoding an answer failed", "error", err.Error())
rq.h.processLog.Error(
"encoding an answer failed",
"error",
err.Error(),
)
http.Error(rq.out, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
+112 -26
View File
@@ -32,20 +32,37 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
t.Parallel()
// The metrics token is set, and opens none of them.
s, clk, server := startWithClock(t, "", map[string]string{metricsToken: token})
server.Ledger.BanForLimit(netip.MustParsePrefix(otherClient+"/32"), clk.Now(),
bans.Notes{})
s, clk, server := startWithClock(
t,
"",
map[string]string{metricsToken: token},
)
server.Ledger.BanForLimit(
netip.MustParsePrefix(otherClient+"/32"),
clk.Now(),
bans.Notes{},
)
before := server.Ledger.Snapshot()
// An empty token does not match the unset one either.
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
for _, e := range adminEndpoints() {
s.ownEndpointRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
s.ownEndpointRequest(
authorization,
e.method,
e.path,
e.body,
http.StatusNotFound,
)
}
}
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before)
t.Errorf(
"the bans are now\n%+v\nwant them unchanged\n%+v",
after,
before,
)
}
}
@@ -67,22 +84,44 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
for _, authorization := range []string{
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
} {
got := s.ownEndpointRequest(authorization, e.method, e.path, e.body,
http.StatusUnauthorized)
got := s.ownEndpointRequest(
authorization,
e.method,
e.path,
e.body,
http.StatusUnauthorized,
)
if got.header.Get("WWW-Authenticate") != "Bearer" {
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
e.method, e.path, authorization)
t.Errorf(
"%s %s with %q was answered without WWW-Authenticate: Bearer",
e.method,
e.path,
authorization,
)
}
}
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
e.method, e.path, after, before)
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(
after,
before,
) {
t.Errorf(
"%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
e.method,
e.path,
after,
before,
)
}
got := s.admin(e.method, e.path, e.body, http.StatusOK)
if got.header.Get("Content-Type") != "application/json" {
t.Errorf("%s %s answered %q", e.method, e.path, got.header.Get("Content-Type"))
t.Errorf(
"%s %s answered %q",
e.method,
e.path,
got.header.Get("Content-Type"),
)
}
}
@@ -121,7 +160,11 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
wantBans(t, s.admin(http.MethodPost, waf.BansPath, banOtherClient,
http.StatusOK), want)
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
line := s.get(
client,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
if line.BanExpires != requestlog.FormatTime(expires) {
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
}
@@ -129,7 +172,11 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
// Its notes count the request it refused.
want.Notes.Requests, want.Notes.Refused = 1, 1
wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want)
wantBans(
t,
s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK),
want,
)
// Ten minutes on, lifting the bans on client lifts that one, which is
// kept, marked lifted.
@@ -141,10 +188,19 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
wantBans(t, s.admin(http.MethodDelete, waf.BansPath+"/"+client, "",
http.StatusOK), want)
s.get(client, http.StatusOK, requestlog.ActionForward)
wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want)
wantBans(
t,
s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK),
want,
)
// No ban on it is active any more.
s.admin(http.MethodDelete, waf.BansPath+"/"+client, "", http.StatusNotFound)
s.admin(
http.MethodDelete,
waf.BansPath+"/"+client,
"",
http.StatusNotFound,
)
}
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
@@ -173,7 +229,9 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
body := `{"netblock": "` + tc.netblock + `", "duration": "` + tc.duration + `"}` +
"\r\n"
want := state.BanEntry{
Netblock: netip.MustParsePrefix(tc.want), Start: start, Cause: bans.CauseAdmin,
Netblock: netip.MustParsePrefix(
tc.want,
), Start: start, Cause: bans.CauseAdmin,
}
if tc.length != 0 {
@@ -181,15 +239,22 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
want.Expires = &expires
}
wantBans(t, s.admin(http.MethodPost, waf.BansPath, body, http.StatusOK),
want)
wantBans(
t,
s.admin(http.MethodPost, waf.BansPath, body, http.StatusOK),
want,
)
}
}
func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{adminToken: adminSecret})
s, _, server := startWithClock(
t,
"",
map[string]string{adminToken: adminSecret},
)
for _, tc := range []struct{ body, want string }{
{"", "the body is not a JSON object of netblock, duration and reason: EOF"},
@@ -254,7 +319,12 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
got := s.admin(http.MethodPost, waf.BansPath, tc.body,
http.StatusBadRequest)
if !strings.Contains(string(got.body), tc.want) {
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
t.Errorf(
"%.80s was answered %q, want it to say %q",
tc.body,
got.body,
tc.want,
)
}
}
@@ -276,7 +346,11 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// limit of two a minute, and bans it.
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(
otherClient,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
// Asked about by its address in IPv6 form too.
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
@@ -302,7 +376,11 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
if len(got.Bans) != 1 || got.Bans[0].Cause != bans.CauseLimit ||
got.Bans[0].Reason != "requests per minute over the limit of 2" ||
got.Bans[0].Notes.Count != 3 {
t.Errorf("%s: bans %+v, want the one for the broken limit", addr, got.Bans)
t.Errorf(
"%s: bans %+v, want the one for the broken limit",
addr,
got.Bans,
)
}
}
@@ -310,7 +388,9 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// known.
got := s.admin(http.MethodGet, waf.ClientsPath+"198.51.100.99", "",
http.StatusOK)
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
if string(
got.body,
) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
t.Errorf("an unknown client is answered\n%s", got.body)
}
@@ -329,7 +409,13 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
})
for _, e := range adminEndpoints() {
s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
s.ownEndpointRequest(
"",
e.method,
e.path,
e.body,
http.StatusUnauthorized,
)
}
if held := server.Ledger.Snapshot(); len(held) != 0 {
+85 -20
View File
@@ -46,7 +46,10 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
}, requestlog.FormatTime(start.Add(time.Hour))))
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)
t.Errorf(
"the alert's notes are %+v, want those of the broken limit",
ban.Notes,
)
}
}
@@ -62,7 +65,12 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
// 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.ActionTemporarilyBanned)
s.request(
client,
"/.env",
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
attackBan := server.Ledger.Bans(netblock)[0]
@@ -83,15 +91,27 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
otherBans := server.Ledger.Bans(other)
wantAlerts(t, queue,
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"),
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",
),
)
}
@@ -108,7 +128,11 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
// A ban for a clear sign of attack, which a request under it would make
// permanent.
group := netip.MustParsePrefix(ipv6Group)
attackBan, _ := server.Ledger.BanForAttack(group, start, bans.Notes{RuleID: "probe"})
attackBan, _ := server.Ledger.BanForAttack(
group,
start,
bans.Notes{RuleID: "probe"},
)
// The third request breaks the limit, and so does the fourth, within the
// cooldown, which raises nothing. The probe is a clear sign of attack.
@@ -123,8 +147,12 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
held := server.Ledger.Snapshot()
if len(held) != 1 || !reflect.DeepEqual(held[0], attackBan) ||
line.BanExpires != requestlog.FormatTime(attackBan.Expires) {
t.Errorf("the ledger holds %+v, and the log line gives %s, want the ban "+
"for the attack alone, as it was", held, line.BanExpires)
t.Errorf(
"the ledger holds %+v, and the log line gives %s, want the ban "+
"for the attack alone, as it was",
held,
line.BanExpires,
)
}
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -138,21 +166,35 @@ func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
if limitNotes.Limit != 2 || limitNotes.Request.Path != "/" ||
attackNotes.Request.Path != "/.env" {
t.Errorf("the notes are %+v and %+v, want those of the broken limit and "+
"of the probe", limitNotes, attackNotes)
t.Errorf(
"the notes are %+v and %+v, want those of the broken limit and "+
"of the probe",
limitNotes,
attackNotes,
)
}
// Each alert is the one enforce mode would have raised, with mode
// observe in its detail.
want := []alerts.Alert{
banAlert(alerts.EventBan, start, client, bans.Ban{
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
Netblock: netip.MustParsePrefix(
client + "/32",
), Cause: bans.CauseLimit,
Reason: "requests per minute over the limit of 2", Notes: limitNotes,
}, requestlog.FormatTime(start.Add(time.Hour))),
attackAlert(alerts.EventBan, start, otherClient, bans.Ban{
Netblock: netip.MustParsePrefix(otherClient + "/32"), Notes: attackNotes,
Netblock: netip.MustParsePrefix(
otherClient + "/32",
), Notes: attackNotes,
}, requestlog.FormatTime(start.Add(7*24*time.Hour))),
attackAlert(alerts.EventPermanentBan, start, ipv6Client, attackBan, permanent),
attackAlert(
alerts.EventPermanentBan,
start,
ipv6Client,
attackBan,
permanent,
),
}
for _, alert := range want {
alert.Detail["mode"] = observe
@@ -180,7 +222,12 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
// The other client's first probe raises the second. Its second probe is
// within the cooldown.
for range 2 {
s.request(otherClient, "/.env", http.StatusOK, requestlog.ActionForward)
s.request(
otherClient,
"/.env",
http.StatusOK,
requestlog.ActionForward,
)
}
// The IPv6 client's third request breaks the limit past the two alerts
@@ -206,7 +253,11 @@ func startWithAlerts(
) (*sender, *clock, *waf.Server, *alerts.Queue) {
t.Helper()
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
return startAppWithAlerts(
t,
func(http.ResponseWriter, *http.Request) {},
env,
)
}
// startAppWithAlerts is startWithAlerts in front of the app handler.
@@ -224,7 +275,13 @@ func startAppWithAlerts(
}
maps.Copy(settings, env)
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
addr, out, server, queue := startProxyWithAlerts(
t,
app.URL,
"",
clk.Now,
settings,
)
s := &sender{t: t, addr: addr, wafAddr: server.Addr, out: out}
@@ -235,7 +292,11 @@ func startAppWithAlerts(
// the time raised, for ban, with its netblock, cause, reason and notes,
// which ends at expires, as the log line gives it.
func banAlert(
event string, raised time.Time, client string, ban bans.Ban, expires string,
event string,
raised time.Time,
client string,
ban bans.Ban,
expires string,
) alerts.Alert {
return alerts.Alert{
Instance: alertInstance,
@@ -254,7 +315,11 @@ func banAlert(
// attackAlert is banAlert for a ban for the probe rule of testRules, with
// the netblock and the notes of ban.
func attackAlert(
event string, raised time.Time, client string, ban bans.Ban, expires string,
event string,
raised time.Time,
client string,
ban bans.Ban,
expires string,
) alerts.Alert {
return banAlert(event, raised, client, bans.Ban{
Netblock: ban.Netblock, Cause: bans.CauseAttack, Reason: "matched the rule probe",
+4 -1
View File
@@ -24,7 +24,10 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("counting the request did work, with every threshold off: %v", r)
t.Errorf(
"counting the request did work, with every threshold off: %v",
r,
)
}
}()
+41 -15
View File
@@ -51,7 +51,9 @@ const (
hour = "hour"
)
func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(
t *testing.T,
) {
t.Parallel()
for _, scope := range []struct {
@@ -95,9 +97,8 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
t.Run(setting, func(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
setting: value, watchNets: office,
})
env := map[string]string{setting: value, watchNets: office}
s, clk, server, queue := startWithLookupsAndClock(t, env)
start := clk.Now()
// The third upload takes the count over the threshold, and the
@@ -156,7 +157,10 @@ func wantAlertedAgainOnceTheCooldownHasRunOut(
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 2 || !waiting[1].Time.Equal(clk.Now()) ||
waiting[1].SuppressedRepeats != 1 {
t.Errorf("alerts wait %+v, want the first and another, with 1 repeat", waiting)
t.Errorf(
"alerts wait %+v, want the first and another, with 1 repeat",
waiting,
)
}
}
@@ -201,7 +205,10 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
got = append(got, alert.Client.String())
}
if want := []string{allowed, exempt, client, denied}; !slices.Equal(got, want) {
if want := []string{allowed, exempt, client, denied}; !slices.Equal(
got,
want,
) {
t.Errorf("alerts for the clients %v, want %v", got, want)
}
}
@@ -210,7 +217,10 @@ func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
t.Parallel()
// With every threshold off, nothing is counted.
s, server, queue := startWithLookups(t, map[string]string{watchNets: office})
s, server, queue := startWithLookups(
t,
map[string]string{watchNets: office},
)
for range 5 {
s.uploadFrom(client)
@@ -306,7 +316,10 @@ func TestClientIsCountedForItsASNumberOnceTheLookupGivesOne(t *testing.T) {
got[counter.ASN] = counter.Minute.Current
}
if want := map[string]int64{asnDE: 2, asnKP: 1, "AS64500": 1}; !maps.Equal(got, want) {
if want := map[string]int64{asnDE: 2, asnKP: 1, "AS64500": 1}; !maps.Equal(
got,
want,
) {
t.Errorf("requests by AS number %v, want %v", got, want)
}
}
@@ -324,7 +337,9 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {
release()
waitUntil(func() bool {
_, kept := server.Load().GeoJS.Kept(netip.MustParsePrefix(fromDE + "/32"))
_, kept := server.Load().GeoJS.Kept(
netip.MustParsePrefix(fromDE + "/32"),
)
return kept
})
@@ -342,7 +357,10 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
// number it gives.
s := &sender{t: t, addr: addr, out: out}
if line := s.get(fromDE, http.StatusOK, requestlog.ActionForward); line.ASN != "" {
t.Errorf("log line has AS number %q, want none: the request waited", line.ASN)
t.Errorf(
"log line has AS number %q, want none: the request waited",
line.ASN,
)
}
want := []anomaly.Counter{{
@@ -357,10 +375,15 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
t.Parallel()
s, _, server, _ := startAppWithAlerts(t, readAndAnswer, map[string]string{
anomalyWatch + requestsPerMinute: aLot,
watchNets: office + ",wide=203.0.0.0/16,other=198.51.100.0/25",
})
s, _, server, _ := startAppWithAlerts(
t,
readAndAnswer,
map[string]string{
anomalyWatch + requestsPerMinute: aLot,
watchNets: office + ",wide=203.0.0.0/16" +
",other=198.51.100.0/25",
},
)
// client is in office and in wide.
for _, from := range []string{client, "203.0.200.1", "192.0.2.7"} {
@@ -372,7 +395,10 @@ func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
got[counter.Name] = counter.Minute.Current
}
if want := map[string]int64{"office": 1, "wide": 2}; !maps.Equal(got, want) {
if want := map[string]int64{"office": 1, "wide": 2}; !maps.Equal(
got,
want,
) {
t.Errorf("requests by named netblock %v, want %v", got, want)
}
}
+185 -48
View File
@@ -75,7 +75,9 @@ func fillHeaders(req *http.Request, total int) {
}
}
func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(t *testing.T) {
func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(
t *testing.T,
) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block})
@@ -90,7 +92,10 @@ func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(t *testin
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
if line.DurationCoreRuleSetMS != nil {
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS)
t.Errorf(
"the Core Rule Set read the request, for %f ms",
*line.DurationCoreRuleSetMS,
)
}
}
@@ -102,8 +107,12 @@ func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
fillHeaders(req, headerFieldLimit+1)
do(t, req)
wantHeuristic(t, s.out.requestLine(t), "header_limits",
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
wantHeuristic(
t,
s.out.requestLine(t),
"header_limits",
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`,
)
}
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
@@ -160,7 +169,12 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
if tc.refused {
wantDisposition(t, got, tooLarge, waf.DispositionDeny)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
wantLine(
t,
line,
tooLarge,
requestlog.ActionRequestHeaderTooLarge,
)
return
}
@@ -169,7 +183,9 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.DurationCoreRuleSetMS == nil {
t.Error("the Core Rule Set did not read a request within the limits")
t.Error(
"the Core Rule Set did not read a request within the limits",
)
}
})
}
@@ -198,7 +214,11 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{wafMode: block, allowNets: client})
s := startWAF(
t,
"",
map[string]string{wafMode: block, allowNets: client},
)
req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
@@ -219,8 +239,12 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
line.DurationCoreRuleSetMS != nil {
t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none",
line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge)
t.Errorf(
"would_action %q and duration_core_rule_set_ms %v, want %q and none",
line.WouldAction,
line.DurationCoreRuleSetMS,
requestlog.ActionRequestHeaderTooLarge,
)
}
}
@@ -235,7 +259,12 @@ func TestTheRateLimitsDoNotCountRequestsOverTheHeaderLimits(t *testing.T) {
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
}
wantDisposition(t, s.ask(client, "/"), http.StatusOK, waf.DispositionAllow)
wantDisposition(
t,
s.ask(client, "/"),
http.StatusOK,
waf.DispositionAllow,
)
}
func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
@@ -310,7 +339,12 @@ func TestTheHeaderLimitsComeBeforeEveryCheckThatRefuses(t *testing.T) {
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
wantLine(t, s.out.requestLine(t), tooLarge, requestlog.ActionRequestHeaderTooLarge)
wantLine(
t,
s.out.requestLine(t),
tooLarge,
requestlog.ActionRequestHeaderTooLarge,
)
})
}
}
@@ -327,9 +361,12 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
})
s := startWAF(t, "", map[string]string{
lookupSource: fileSource, lookupDBPath: path,
tagLists: "vpn=" + vpnList,
routeWatchURL: startRouteWatch(t, map[string]standInRoute{client: {prefixDE, 64496}}),
dnsblZones: dnsblZone, reputationAction: actionLog,
tagLists: "vpn=" + vpnList,
routeWatchURL: startRouteWatch(
t,
map[string]standInRoute{client: {prefixDE, 64496}},
),
dnsblZones: dnsblZone, reputationAction: actionLog,
rulesDir: writeRules(t, "noted path log ^/\n"),
wafMode: block,
})
@@ -351,19 +388,34 @@ func TestARequestOverTheHeaderLimitsRunsNoOtherCheck(t *testing.T) {
if lines[0].RuleIDs != nil || lines[0].Counts != (ratelimit.Counts{}) ||
lines[0].DurationCoreRuleSetMS != nil {
t.Errorf("rule ids %v, counts %+v and Core Rule Set time %v, want none",
lines[0].RuleIDs, lines[0].Counts, lines[0].DurationCoreRuleSetMS)
t.Errorf(
"rule ids %v, counts %+v and Core Rule Set time %v, want none",
lines[0].RuleIDs,
lines[0].Counts,
lines[0].DurationCoreRuleSetMS,
)
}
// The request within the limits shows that each check leaves its mark.
wantTags(t, lines[1],
"asn=64496", "country=DE", "dnsbl="+dnsblZone, "exploitsignature", "vpn")
wantTags(
t,
lines[1],
"asn=64496",
"country=DE",
"dnsbl="+dnsblZone,
"exploitsignature",
"vpn",
)
wantTagsMissing(t, lines[1], "no_answer_yet")
if lines[1].RuleIDs == nil || lines[1].Counts.Minute != 1 ||
lines[1].DurationCoreRuleSetMS == nil {
t.Errorf("rule ids %v, counts %+v and Core Rule Set time %v, want each",
lines[1].RuleIDs, lines[1].Counts, lines[1].DurationCoreRuleSetMS)
t.Errorf(
"rule ids %v, counts %+v and Core Rule Set time %v, want each",
lines[1].RuleIDs,
lines[1].Counts,
lines[1].DurationCoreRuleSetMS,
)
}
}
@@ -378,17 +430,29 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
})
countClient(t, s.server, s.clock.Now())
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden, waf.DispositionDeny)
wantDisposition(
t,
s.ask(fromDE, "/"),
http.StatusForbidden,
waf.DispositionDeny,
)
line := s.out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantLine(
t,
line,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
if line.RateLimitPercent != nil {
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
}
}
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(
t *testing.T,
) {
t.Parallel()
// The client's next request takes it over the whole limit, so it does
@@ -403,17 +467,29 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
}})
countClient(t, s.server, s.clock.Now())
wantDisposition(t, s.ask(client, "/"), http.StatusForbidden, waf.DispositionDeny)
wantDisposition(
t,
s.ask(client, "/"),
http.StatusForbidden,
waf.DispositionDeny,
)
line := s.out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantLine(
t,
line,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
if line.RateLimitPercent == nil || *line.RateLimitPercent != 0 {
t.Errorf("limit percentage %v, want 0", line.RateLimitPercent)
}
}
func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(t *testing.T) {
func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(
t *testing.T,
) {
t.Parallel()
geojsURL, asked := startGeoJS(t)
@@ -427,8 +503,12 @@ func TestObserveModeAsksGeoJSNothingForARequestOverTheHeaderLimits(t *testing.T)
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
if len(asked()) != 0 || got.header.Get("X-Client-Asn") != "" {
t.Errorf("GeoJS was asked about %v, and the answer gave the AS number %q, "+
"want neither", asked(), got.header.Get("X-Client-Asn"))
t.Errorf(
"GeoJS was asked about %v, and the answer gave the AS number %q, "+
"want neither",
asked(),
got.header.Get("X-Client-Asn"),
)
}
}
@@ -455,7 +535,12 @@ func askDirectly(
) (int, time.Duration) {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req := httptest.NewRequestWithContext(
t.Context(),
http.MethodGet,
"/",
http.NoBody,
)
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
req.Header.Set(waf.ClientHeader, client)
req.Header.Set(requestIDHeader, "the-id")
@@ -471,12 +556,20 @@ func askDirectly(
// banClient bans client at now.
func banClient(_ *testing.T, server *waf.Server, now time.Time) {
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
server.Ledger.BanForLimit(
netip.MustParsePrefix(client+"/32"),
now,
bans.Notes{},
)
}
// countClient counts one request from client at now for the rate limits.
func countClient(_ *testing.T, server *waf.Server, now time.Time) {
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
server.Limiter.Count(
netip.MustParsePrefix(client+"/32"),
now,
wholeLimit,
)
}
// listClient puts client on the blocklist at dropURL.
@@ -559,13 +652,22 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
server, out := newWAFInBubble(t, tc.env)
tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(req *http.Request) {
req.Header.Set(cookieHeader, tc.cookie)
})
status, took := askDirectly(
t,
server,
func(req *http.Request) {
req.Header.Set(cookieHeader, tc.cookie)
},
)
if status != tc.status || took != 0 {
t.Errorf("answered %d after %s, want %d at once; output:\n%s",
status, took, tc.status, out.text())
t.Errorf(
"answered %d after %s, want %d at once; output:\n%s",
status,
took,
tc.status,
out.text(),
)
}
wantLine(t, out.requestLine(t), tc.status, tc.action)
@@ -577,7 +679,9 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
}
}
func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
@@ -616,11 +720,21 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
server, out := newWAFInBubble(t, tc.env)
tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(*http.Request) {})
status, took := askDirectly(
t,
server,
func(*http.Request) {},
)
if status != tc.status || took != defaultLookupWait {
t.Errorf("answered %d after %s, want %d after %s; output:\n%s",
status, took, tc.status, defaultLookupWait, out.text())
t.Errorf(
"answered %d after %s, want %d after %s; output:\n%s",
status,
took,
tc.status,
defaultLookupWait,
out.text(),
)
}
wantLine(t, out.requestLine(t), tc.status, tc.action)
@@ -664,7 +778,11 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
synctest.Test(t, func(t *testing.T) {
// AbuseIPDB is asked only about a client with an offence.
group := netip.MustParsePrefix(client + "/32")
server.Limiter.AddToHistory(group, time.Now(), ratelimit.Request{Attack: true})
server.Limiter.AddToHistory(
group,
time.Now(),
ratelimit.Request{Attack: true},
)
status, took := askDirectly(t, server, func(req *http.Request) {
req.Header.Set("User-Agent", "test")
@@ -673,9 +791,17 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
})
// The wait for GeoJS decides when the answer comes.
if status != http.StatusOK || took != defaultLookupWait || took >= answerTimeLimit {
t.Errorf("answered %d after %s, want %d after %s, within %s; output:\n%s",
status, took, http.StatusOK, defaultLookupWait, answerTimeLimit, out.text())
if status != http.StatusOK || took != defaultLookupWait ||
took >= answerTimeLimit {
t.Errorf(
"answered %d after %s, want %d after %s, within %s; output:\n%s",
status,
took,
http.StatusOK,
defaultLookupWait,
answerTimeLimit,
out.text(),
)
}
// Each outside source was asked, and none has answered. GeoJS is asked
@@ -686,10 +812,17 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
// The resolver holds the query until it is released below, so a waf
// that waited for the query would answer only once it had failed.
queries, failures := server.DNSBL.Queries(dnsblZone), server.DNSBL.Failures(dnsblZone)
queries, failures := server.DNSBL.Queries(
dnsblZone,
), server.DNSBL.Failures(
dnsblZone,
)
if queries != 1 || failures != 0 {
t.Errorf("%d queries to the DNS blocklist, %d failed, want 1, none failed",
queries, failures)
t.Errorf(
"%d queries to the DNS blocklist, %d failed, want 1, none failed",
queries,
failures,
)
}
if checked := server.AbuseIPDB.Checked(); checked != 1 {
@@ -721,7 +854,11 @@ const (
func startHeldResolver(t *testing.T) (string, func()) {
t.Helper()
conn, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", localhost+":0")
conn, err := (&net.ListenConfig{}).ListenPacket(
t.Context(),
"udp",
localhost+":0",
)
if err != nil {
t.Fatalf("listen: %v", err)
}
+33 -10
View File
@@ -29,7 +29,11 @@ type activeBan struct{}
func (activeBan) name() string { return "active_ban" }
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
func (activeBan) check(
_ context.Context,
rq *request,
now time.Time,
) *verdict {
check := rq.h.ledger.Check
if rq.h.config.Observe {
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
@@ -101,7 +105,11 @@ func (rq *request) countedBytes() int64 {
// the client's counters back to zero. In observe mode it makes no ban and
// sets nothing back, and raises the alert for the ban it would have made,
// if that alert would be sent.
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
func (rq *request) banForLimit(
now time.Time,
hit ratelimit.Hit,
status int,
) {
switch hit.Kind {
case ratelimit.KindBytes:
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
@@ -115,7 +123,8 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
rq.h.metrics.LimitHit(hit)
netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
if rq.h.config.Observe &&
!rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
return
}
@@ -164,8 +173,11 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
// alert would be sent. It returns the action for that ban.
func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
if rq.h.config.Observe &&
!rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
return banAction(
rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack),
)
}
notes.ASN = rq.line.ASN
@@ -198,10 +210,16 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
// CrowdSec's decision on the client, ends. In observe mode it makes no
// ban, and raises the alert for the ban it would have made, if that alert
// would be sent. It returns the action for that ban.
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string {
func (rq *request) banForCrowdSec(
now time.Time,
decision reputation.Decision,
) string {
netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
if rq.h.config.Observe &&
!rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
return banAction(
rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec),
)
}
notes := bans.Notes{
@@ -214,8 +232,13 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
}
if rq.h.config.Observe {
ban, wouldBan := rq.h.ledger.WouldBanForCrowdSec(netblock, now, decision.Expires,
decision.Scenario, notes)
ban, wouldBan := rq.h.ledger.WouldBanForCrowdSec(
netblock,
now,
decision.Expires,
decision.Scenario,
notes,
)
if wouldBan {
rq.alertBan(ban)
}
+115 -26
View File
@@ -33,29 +33,52 @@ const (
func TestBrokenLimitBansTheClient(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
s, clk, _ := startWithClock(
t,
"",
map[string]string{rateLimitPerMinute: "1"},
)
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
// The request over the limit of one a minute is refused, and bans the
// client for an hour, the default.
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(
client,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
line.BanExpires != expires {
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
"want minute, limit and %s", line.LimitHit, line.Offence, line.BanExpires,
expires)
t.Errorf(
"log line has limit_hit %q, offence %q and ban_expires %q, "+
"want minute, limit and %s",
line.LimitHit,
line.Offence,
line.BanExpires,
expires,
)
}
// Every request while the ban lasts is refused.
clk.advance(time.Hour - time.Second)
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
"want %s and neither of the others", line.BanExpires, line.Offence,
line.LimitHit, expires)
line = s.get(
client,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
if line.BanExpires != expires || line.Offence != "" ||
line.LimitHit != "" {
t.Errorf(
"log line has ban_expires %q, offence %q and limit_hit %q, "+
"want %s and neither of the others",
line.BanExpires,
line.Offence,
line.LimitHit,
expires,
)
}
// Once it ends, the client is let through.
@@ -78,7 +101,11 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
breakLimit := func() string {
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(
client,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
return line.BanExpires
}
@@ -119,7 +146,11 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{rateLimitPerDay: "2"})
s, clk, server := startWithClock(
t,
"",
map[string]string{rateLimitPerDay: "2"},
)
// The third request in a day is over the limit of two, and bans the
// client for an hour.
@@ -128,7 +159,11 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
for range 3 {
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(
client,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
}
// Later the same day the client has its whole allowance again: the
@@ -141,7 +176,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
t.Errorf("bans %+v, want two, the first with 3 requests refused", banned)
t.Errorf(
"bans %+v, want two, the first with 3 requests refused",
banned,
)
}
}
@@ -192,10 +230,18 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
s, _, _ := startWithClock(t, "", env)
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(
tc.breaker,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
for _, sent := range tc.refused {
s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(
sent,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
}
for _, sent := range tc.let {
@@ -220,7 +266,11 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
line := s.get(
otherClient,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
if line.Country != "" {
t.Errorf("log line has country %q, want none", line.Country)
}
@@ -241,7 +291,11 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
line := s.get(
otherClient,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
wantHeuristic(t, line, "active_ban",
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
}
@@ -347,8 +401,12 @@ func TestBanNotes(t *testing.T) {
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
got = ledger.Bans(netblock)
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
t.Errorf("bans %+v, want two, the second with one earlier ban for a limit", got)
if len(got) != 2 ||
got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
t.Errorf(
"bans %+v, want two, the second with one earlier ban for a limit",
got,
)
}
}
@@ -364,10 +422,18 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(
otherClient,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(
otherClient,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
}
// clock is the time a test sets, by which sww counts requests and
@@ -408,9 +474,20 @@ func startWithClock(
settings := map[string]string{trustedProxies: trustLocalhost}
maps.Copy(settings, env)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
addr, out, server := startProxyWithClock(
t,
app.URL,
geojsURL,
clk.Now,
settings,
)
return &sender{t: t, addr: addr, wafAddr: server.Addr, out: out}, clk, server
return &sender{
t: t,
addr: addr,
wafAddr: server.Addr,
out: out,
}, clk, server
}
// sender sends requests to rproxy one after another, each on a connection of its
@@ -437,7 +514,11 @@ func (s *sender) get(from string, status int, action string) logLine {
// X-Forwarded-For names it, and checks that its answer and its log line
// have status, 0 for the connection closed without an answer, and that
// the line has action. It returns the log line.
func (s *sender) request(from, path string, status int, action string) logLine {
func (s *sender) request(
from, path string,
status int,
action string,
) logLine {
s.t.Helper()
return s.requestWithHeader(from, path, "", status, action)
@@ -450,7 +531,15 @@ func (s *sender) requestWithHeader(
) logLine {
s.t.Helper()
line, _ := s.requestWithBody(http.MethodGet, from, path, header, "", status, action)
line, _ := s.requestWithBody(
http.MethodGet,
from,
path,
header,
"",
status,
action,
)
return line
}
+41 -13
View File
@@ -22,8 +22,10 @@ type percentage struct {
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
return len(cfg.ASNBytesPercent) > 0 ||
len(cfg.CountryBytesPercent) > 0 ||
cfg.UnknownLimitPercent < whole ||
cfg.ASNLimitPercentURL != ""
}
// limitPercentages returns the client's limit percentages, for the rate
@@ -48,7 +50,10 @@ func (rq *request) limitPercentages() (percentage, percentage) {
unknown := percentage{percent: whole}
if country == "" && !rq.skippedLookupWait {
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
unknown = percentage{
cfg.UnknownLimitPercent,
"SWW_UNKNOWN_LIMIT_PERCENT",
}
}
fetched := percentage{percent: whole}
@@ -58,24 +63,41 @@ func (rq *request) limitPercentages() (percentage, percentage) {
blocklisted := percentage{percent: whole}
if rq.blocklisted && cfg.BlocklistAction == "limit" {
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
blocklisted = percentage{
cfg.BlocklistLimitPercent,
"SWW_BLOCKLIST_ACTION",
}
}
reputationListed := percentage{percent: whole}
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
if (rq.dnsblListed || rq.abuseIPDBHit) &&
cfg.ReputationAction == "limit" {
reputationListed = percentage{
cfg.ReputationLimitPercent,
"SWW_REPUTATION_ACTION",
}
}
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
asnRequests := lowest(
given(cfg.TagLimitPercent, asnTag(asn), tagSetting),
fetched,
)
countryRequests := given(
cfg.TagLimitPercent,
countryTag(country),
tagSetting,
)
otherTags := percentage{percent: whole}
for _, tag := range rq.line.Tags {
if tag != asnTag(asn) && tag != countryTag(country) {
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
otherTags = lowest(
otherTags,
given(cfg.TagLimitPercent, tag, tagSetting),
)
}
}
@@ -85,12 +107,18 @@ func (rq *request) limitPercentages() (percentage, percentage) {
}
if _, listed := cfg.CountryBytesPercent[country]; listed {
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
countryBytes = given(
cfg.CountryBytesPercent,
country,
"SWW_COUNTRY_BYTES_PERCENT",
)
}
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
reputationListed),
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
// The limits that lower requests and bytes alike.
common := lowest(otherTags, unknown, blocklisted, reputationListed)
return lowest(asnRequests, countryRequests, common),
lowest(asnBytes, countryBytes, common)
}
// given returns the percentage percents, the setting named setting, gives
+115 -28
View File
@@ -86,7 +86,11 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
// fromKP, which no setting lists, has the whole limit.
for range 3 {
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
line := s.get(
fromKP,
http.StatusOK,
requestlog.ActionForward,
)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
none)
@@ -118,7 +122,11 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
// The upload's 100 bytes are over half of 199, 99.
line := s.uploadFrom(tc.from)
if line.LimitHit != minuteBytes {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
t.Errorf(
"log line has limit_hit %q, want %s",
line.LimitHit,
minuteBytes,
)
}
wantPercent(t, "bytes_limit_percent",
@@ -128,7 +136,11 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
// fromKP, which no setting lists, has the whole limit.
line = s.uploadFrom(fromKP)
if line.LimitHit != "" {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
t.Errorf(
"log line for %s has limit_hit %q, want none",
fromKP,
line.LimitHit,
)
}
wantPercent(t, "bytes_limit_percent",
@@ -137,7 +149,9 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
}
}
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T) {
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
@@ -178,7 +192,11 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
// The upload's 100 bytes are over 99, half of 199, and within 199.
line := s.uploadFrom(fromDE)
if line.LimitHit != tc.limitHit {
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
t.Errorf(
"log line has limit_hit %q, want %q",
line.LimitHit,
tc.limitHit,
)
}
wantPercent(t, "rate_limit_percent",
@@ -194,16 +212,26 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
s, _, _ := startWithLookups(
t,
map[string]string{unknownLimitPercent: "0"},
)
// The first request breaks the limit, and bans the client; the log line
// gives the 0.
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(
unplaced,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
if line.fields["rate_limit_percent"] != float64(0) ||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
t.Errorf("log line has rate_limit_percent %v from %v, want 0 from %s",
line.fields["rate_limit_percent"], line.fields["rate_limit_percent_setting"],
unknownLimitPercent)
t.Errorf(
"log line has rate_limit_percent %v from %v, want 0 from %s",
line.fields["rate_limit_percent"],
line.fields["rate_limit_percent_setting"],
unknownLimitPercent,
)
}
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
@@ -256,7 +284,11 @@ func TestLowestPercentageApplies(t *testing.T) {
// A quarter of 4 requests a minute: the second breaks the limit.
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(
tc.from,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want)
@@ -264,7 +296,9 @@ func TestLowestPercentageApplies(t *testing.T) {
}
}
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *testing.T) {
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
t *testing.T,
) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{
@@ -276,7 +310,11 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test
for _, from := range []string{unplaced, "10.0.0.8"} {
s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(
from,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
}
// One in a country has the whole limit.
@@ -300,7 +338,12 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
serveFromDE(t, server, http.MethodGet, http.NoBody)
line := out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantLine(
t,
line,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"0 from "+unknownLimitPercent)
@@ -341,7 +384,12 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
})
began := time.Now()
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
serveFromDE(
t,
server,
http.MethodPost,
strings.NewReader("ab"),
)
want := time.Duration(0)
if tc.waits {
@@ -349,10 +397,19 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
}
if waited := time.Since(began); waited != want {
t.Errorf("the request waited %s for its answer, want %s", waited, want)
t.Errorf(
"the request waited %s for its answer, want %s",
waited,
want,
)
}
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
wantLine(
t,
out.requestLine(t),
http.StatusOK,
requestlog.ActionForward,
)
// The bubble's clock stops once this function returns, so the
// request to GeoJS, which a request that did not wait leaves
@@ -363,7 +420,9 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
}
}
func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T) {
func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
@@ -405,8 +464,8 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
s.uploadFrom(fromDE)
}
s.requestWithBody(http.MethodPost, fromDE, "/", uploadHeader, uploadBody,
tc.status, tc.action)
s.requestWithBody(http.MethodPost, fromDE, "/", uploadHeader,
uploadBody, tc.status, tc.action)
held := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32"))
if len(held) != 1 {
@@ -415,7 +474,11 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
notes := held[0].Notes
if held[0].Reason != tc.reason {
t.Errorf("the ban's reason is %q, want %q", held[0].Reason, tc.reason)
t.Errorf(
"the ban's reason is %q, want %q",
held[0].Reason,
tc.reason,
)
}
wantPercent(t, "the notes' limit_percent", notes.LimitPercent,
@@ -423,12 +486,21 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 {
t.Fatalf("%d alerts wait, want the ban's alone: %+v", len(waiting), waiting)
t.Fatalf(
"%d alerts wait, want the ban's alone: %+v",
len(waiting),
waiting,
)
}
alerted, _ := waiting[0].Detail["notes"].(bans.Notes)
wantPercent(t, "the alert's notes' limit_percent", alerted.LimitPercent,
alerted.LimitPercentSetting, tc.want)
wantPercent(
t,
"the alert's notes' limit_percent",
alerted.LimitPercent,
alerted.LimitPercentSetting,
tc.want,
)
})
}
}
@@ -462,7 +534,10 @@ func startWithLookupsAndClock(
noCountry + "/32": {ASN: "AS64500", ASName: "Nowhere Net"},
})
settings := map[string]string{lookupSource: fileSource, lookupDBPath: path}
settings := map[string]string{
lookupSource: fileSource,
lookupDBPath: path,
}
maps.Copy(settings, env)
return startAppWithAlerts(t, readAndAnswer, settings)
@@ -472,8 +547,15 @@ func startWithLookupsAndClock(
func (s *sender) uploadFrom(from string) logLine {
s.t.Helper()
line, _ := s.requestWithBody(http.MethodPost, from, "/", uploadHeader, uploadBody,
http.StatusOK, requestlog.ActionForward)
line, _ := s.requestWithBody(
http.MethodPost,
from,
"/",
uploadHeader,
uploadBody,
http.StatusOK,
requestlog.ActionForward,
)
return line
}
@@ -496,7 +578,12 @@ func serveFromDE(
// wantPercent checks a limit percentage that a log line or a ban's notes
// give, what, and the setting that gave it, against want, as percentText
// gives them.
func wantPercent(t *testing.T, what string, percent *int64, setting, want string) {
func wantPercent(
t *testing.T,
what string,
percent *int64,
setting, want string,
) {
t.Helper()
if got := percentText(percent, setting); got != want {
+5 -1
View File
@@ -18,7 +18,11 @@ type blocklists struct{}
func (blocklists) name() string { return "blocklists" }
func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
func (blocklists) check(
_ context.Context,
rq *request,
_ time.Time,
) *verdict {
listedBy := rq.h.lists.ListedBy(rq.client)
rq.blocklisted = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a blocklist")
+11 -3
View File
@@ -20,13 +20,21 @@ type byteLimits struct{}
func (byteLimits) name() string { return "byte_limits" }
func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
func (byteLimits) check(
_ context.Context,
rq *request,
now time.Time,
) *verdict {
if !rq.counted || rq.line.WouldAction != "" {
return nil
}
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesLimitPercent.percent)
counts, hit, over := rq.h.limiter.CountBytes(
rq.h.clientGroup(rq.client),
now,
rq.countedBytes(),
rq.bytesLimitPercent.percent,
)
rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes
+211 -65
View File
@@ -67,8 +67,11 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
// 70 bytes are within the limit of 99.
line, _ := s.download()
if line.LimitHit != "" || line.Offence != "" {
t.Errorf("log line has limit_hit %q and offence %q, want neither",
line.LimitHit, line.Offence)
t.Errorf(
"log line has limit_hit %q and offence %q, want neither",
line.LimitHit,
line.Offence,
)
}
// 140 bytes are over it. The response is passed on whole, and
@@ -79,18 +82,35 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
line, got := s.download()
if got.err != nil || len(got.body) != answerBytes ||
line.ResponseBytes != answerBytes {
t.Errorf("got %d bytes (%v), and the log line has response_bytes %d, "+
"want %d", len(got.body), got.err, line.ResponseBytes, answerBytes)
t.Errorf(
"got %d bytes (%v), and the log line has response_bytes %d, "+
"want %d",
len(got.body),
got.err,
line.ResponseBytes,
answerBytes,
)
}
if line.LimitHit != tc.window+"_bytes" || line.Offence != requestlog.OffenceLimit ||
if line.LimitHit != tc.window+"_bytes" ||
line.Offence != requestlog.OffenceLimit ||
line.BanExpires != expires {
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
"want %s_bytes, limit and %s", line.LimitHit, line.Offence,
line.BanExpires, tc.window, expires)
t.Errorf(
"log line has limit_hit %q, offence %q and ban_expires %q, "+
"want %s_bytes, limit and %s",
line.LimitHit,
line.Offence,
line.BanExpires,
tc.window,
expires,
)
}
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(
client,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
@@ -101,7 +121,10 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
s, _ := startWithAnswers(
t,
map[string]string{bytesLimitPerMinute: byteLimit},
)
line, _ := s.download()
wantAfterAnswerHeuristic(t, line, "", "null")
@@ -120,9 +143,16 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
// The answer's 70 bytes are over the limit of 50 on their own.
line, got := s.download()
if got.err != nil || len(got.body) != answerBytes || line.LimitHit != minuteBytes {
t.Errorf("got %d bytes (%v), and the log line has limit_hit %q, want %d and %s",
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
if got.err != nil || len(got.body) != answerBytes ||
line.LimitHit != minuteBytes {
t.Errorf(
"got %d bytes (%v), and the log line has limit_hit %q, want %d and %s",
len(got.body),
got.err,
line.LimitHit,
answerBytes,
minuteBytes,
)
}
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
@@ -138,13 +168,27 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
// The 70 bytes passed on before the app broke off are over the limit of
// 50, and ban the client for an hour.
line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
requestlog.ActionForward)
line, got := s.requestWithBody(
http.MethodGet,
client,
"/",
"",
"",
http.StatusOK,
requestlog.ActionForward,
)
if len(got.body) != answerBytes || line.LimitHit != minuteBytes ||
line.BanExpires != expires {
t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+
"want %d, %s and %s", len(got.body), line.LimitHit, line.BanExpires,
answerBytes, minuteBytes, expires)
t.Errorf(
"got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+
"want %d, %s and %s",
len(got.body),
line.LimitHit,
line.BanExpires,
answerBytes,
minuteBytes,
expires,
)
}
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
@@ -164,19 +208,33 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
t.Run(tc.setting, func(t *testing.T) {
t.Parallel()
s, _, _, _ := startAppWithAlerts(t, answerAfterUpgrade, map[string]string{
bytesLimitPerMinute: "29", bytesCount: tc.setting,
})
s, _, _, _ := startAppWithAlerts(
t,
answerAfterUpgrade,
map[string]string{
bytesLimitPerMinute: "29", bytesCount: tc.setting,
},
)
// The client sends 30 bytes and the app 70, each over the limit
// of 29, which bans the client once the WebSocket has closed.
line := s.webSocket()
if line.LimitHit != minuteBytes || line.Counts.MinuteBytes != tc.counted {
t.Errorf("log line has limit_hit %q and minute_bytes %v, want %s and %v",
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
if line.LimitHit != minuteBytes ||
line.Counts.MinuteBytes != tc.counted {
t.Errorf(
"log line has limit_hit %q and minute_bytes %v, want %s and %v",
line.LimitHit,
line.Counts.MinuteBytes,
minuteBytes,
tc.counted,
)
}
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(
client,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
})
}
}
@@ -211,14 +269,25 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
}
counted := float64(i) * tc.each
if line.LimitHit != want || line.Counts.MinuteBytes != counted {
t.Errorf("request %d: log line has limit_hit %q and minute_bytes %v, "+
"want %q and %v", i, line.LimitHit, line.Counts.MinuteBytes,
want, counted)
if line.LimitHit != want ||
line.Counts.MinuteBytes != counted {
t.Errorf(
"request %d: log line has limit_hit %q and minute_bytes %v, "+
"want %q and %v",
i,
line.LimitHit,
line.Counts.MinuteBytes,
want,
counted,
)
}
}
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(
client,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
})
}
}
@@ -243,11 +312,24 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
{allowed, "/"}, {exempt, "/"}, {client, "/assets/app.js"},
} {
for range 2 {
line, _ := s.requestWithBody(http.MethodPost, sent.from, sent.path,
uploadHeader, uploadBody, http.StatusOK, requestlog.ActionForward)
if _, counted := line.fields["counts"]; counted || line.LimitHit != "" {
t.Errorf("%s %s: log line has counts %v and limit_hit %q, want neither",
sent.from, sent.path, line.fields["counts"], line.LimitHit)
line, _ := s.requestWithBody(
http.MethodPost,
sent.from,
sent.path,
uploadHeader,
uploadBody,
http.StatusOK,
requestlog.ActionForward,
)
if _, counted := line.fields["counts"]; counted ||
line.LimitHit != "" {
t.Errorf(
"%s %s: log line has counts %v and limit_hit %q, want neither",
sent.from,
sent.path,
line.fields["counts"],
line.LimitHit,
)
}
}
}
@@ -255,7 +337,11 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
// A path that is not exempt is counted, and breaks the limit.
line := s.upload()
if line.LimitHit != minuteBytes {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
t.Errorf(
"log line has limit_hit %q, want %s",
line.LimitHit,
minuteBytes,
)
}
}
@@ -272,9 +358,14 @@ func TestByteLimitsOffCountTheBytesAndBanNoOne(t *testing.T) {
counted := float64(i * (bodyBytes + answerBytes))
if line.LimitHit != "" || line.Counts.MinuteBytes != counted ||
line.Counts.HourBytes != counted || line.Counts.DayBytes != counted {
t.Errorf("request %d: log line has limit_hit %q and counts %+v, "+
"want none and %v bytes in each window", i, line.LimitHit,
line.Counts, counted)
t.Errorf(
"request %d: log line has limit_hit %q and counts %+v, "+
"want none and %v bytes in each window",
i,
line.LimitHit,
line.Counts,
counted,
)
}
}
}
@@ -282,13 +373,24 @@ func TestByteLimitsOffCountTheBytesAndBanNoOne(t *testing.T) {
func TestBanForABrokenByteLimitHasItsNotesAndItsAlert(t *testing.T) {
t.Parallel()
s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
bytesLimitPerMinute: byteLimit,
})
s, clk, server, queue := startAppWithAlerts(
t,
readAndAnswer,
map[string]string{
bytesLimitPerMinute: byteLimit,
},
)
start := clk.Now()
s.requestWithBody(http.MethodPost, client, "/upload?part=1", uploadHeader,
uploadBody, http.StatusOK, requestlog.ActionForward)
s.requestWithBody(
http.MethodPost,
client,
"/upload?part=1",
uploadHeader,
uploadBody,
http.StatusOK,
requestlog.ActionForward,
)
netblock := netip.MustParsePrefix(client + "/32")
want := bans.Ban{
@@ -331,10 +433,14 @@ func TestBanForABrokenByteLimitHasItsNotesAndItsAlert(t *testing.T) {
func TestObserveModeLogsAndAlertsAByteLimitAndBansNoOne(t *testing.T) {
t.Parallel()
s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
mode: observe,
bytesLimitPerMinute: byteLimit,
})
s, clk, server, queue := startAppWithAlerts(
t,
readAndAnswer,
map[string]string{
mode: observe,
bytesLimitPerMinute: byteLimit,
},
)
start := clk.Now()
// No ban sets the client's counters back to zero, so each request
@@ -344,11 +450,17 @@ func TestObserveModeLogsAndAlertsAByteLimitAndBansNoOne(t *testing.T) {
line := s.upload()
wantWouldAction(t, line, "")
if line.LimitHit != minuteBytes || line.Offence != requestlog.OffenceLimit ||
if line.LimitHit != minuteBytes ||
line.Offence != requestlog.OffenceLimit ||
line.BanExpires != "" {
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
"want %s, limit and none", line.LimitHit, line.Offence, line.BanExpires,
minuteBytes)
t.Errorf(
"log line has limit_hit %q, offence %q and ban_expires %q, "+
"want %s, limit and none",
line.LimitHit,
line.Offence,
line.BanExpires,
minuteBytes,
)
}
}
@@ -363,14 +475,18 @@ func TestObserveModeLogsAndAlertsAByteLimitAndBansNoOne(t *testing.T) {
notes, _ := waiting[0].Detail["notes"].(bans.Notes)
alert := banAlert(alerts.EventBan, start, client, bans.Ban{
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
Netblock: netip.MustParsePrefix(
client + "/32",
), Cause: bans.CauseLimit,
Reason: "bytes per minute over the limit of " + byteLimit, Notes: notes,
}, requestlog.FormatTime(start.Add(time.Hour)))
alert.Detail["mode"] = observe
wantAlerts(t, queue, alert)
}
func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T) {
func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(
t *testing.T,
) {
t.Parallel()
s, _ := startWithAnswers(t, map[string]string{
@@ -388,9 +504,14 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
line := s.upload()
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
line.LimitHit, line.Counts.MinuteBytes, bodyBytes+answerBytes)
if line.LimitHit != minute ||
line.Counts.MinuteBytes != bodyBytes+answerBytes {
t.Errorf(
"log line has limit_hit %q and minute_bytes %v, want minute and %d",
line.LimitHit,
line.Counts.MinuteBytes,
bodyBytes+answerBytes,
)
}
}
@@ -440,7 +561,9 @@ func answerAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
return
}
_, _ = buffered.WriteString(strings.Repeat("a", answerBytes-1) + "\n")
_, _ = buffered.WriteString(
strings.Repeat("a", answerBytes-1) + "\n",
)
_ = buffered.Flush()
}
}
@@ -470,8 +593,12 @@ func (s *sender) openWebSocket() (net.Conn, *bufio.Reader) {
s.t.Helper()
conn := dial(s.t, s.addr)
send(s.t, conn, "GET /socket HTTP/1.1\r\nHost: "+appHost+"\r\n"+forwardedFor+
": "+client+"\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
send(
s.t,
conn,
"GET /socket HTTP/1.1\r\nHost: "+appHost+"\r\n"+forwardedFor+
": "+client+"\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n",
)
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
if err != nil {
@@ -488,7 +615,11 @@ func (s *sender) openWebSocket() (net.Conn, *bufio.Reader) {
_ = res.Body.Close()
if res.StatusCode != http.StatusSwitchingProtocols {
s.t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
s.t.Fatalf(
"status %d, want %d",
res.StatusCode,
http.StatusSwitchingProtocols,
)
}
return conn, reader
@@ -503,14 +634,22 @@ func (s *sender) closeWebSocket(conn net.Conn) logLine {
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
s.sent++
wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward)
wantLine(
s.t,
line,
http.StatusSwitchingProtocols,
requestlog.ActionForward,
)
return line
}
// startWithAnswers is startAppWithAlerts in front of readAndAnswer, for a
// test that looks at neither the server nor the alerts.
func startWithAnswers(t *testing.T, env map[string]string) (*sender, *clock) {
func startWithAnswers(
t *testing.T,
env map[string]string,
) (*sender, *clock) {
t.Helper()
s, clk, _, _ := startAppWithAlerts(t, readAndAnswer, env)
@@ -524,8 +663,15 @@ func startWithAnswers(t *testing.T, env map[string]string) (*sender, *clock) {
func (s *sender) download() (logLine, answer) {
s.t.Helper()
return s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
requestlog.ActionForward)
return s.requestWithBody(
http.MethodGet,
client,
"/",
"",
"",
http.StatusOK,
requestlog.ActionForward,
)
}
// upload is download for a POST request with a body of bodyBytes, and
+15 -5
View File
@@ -25,9 +25,14 @@ type coreRuleSet struct{}
func (coreRuleSet) name() string { return "core_rule_set" }
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
func (coreRuleSet) check(
_ context.Context,
rq *request,
_ time.Time,
) *verdict {
cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
if cfg.WAFMode == config.WAFModeOff ||
pathExempt(rq.in, cfg.WAFExemptPaths) {
return nil
}
@@ -38,7 +43,9 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
rq.requestBytes = int64(len(read))
rq.line.DurationCoreRuleSetMS = new(requestlog.Milliseconds(time.Since(start)))
rq.line.DurationCoreRuleSetMS = new(
requestlog.Milliseconds(time.Since(start)),
)
rq.line.CoreRuleSetRuleIDs = result.RuleIDs
rq.line.CoreRuleSetScore = &result.Score
@@ -61,8 +68,11 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
rq.blockedByCoreRuleSet = true
return &verdict{
action: requestlog.ActionBlockedByCoreRuleSet,
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
action: requestlog.ActionBlockedByCoreRuleSet,
figures: map[string]any{
figureScore: result.Score,
"threshold": threshold,
},
}
}
+184 -49
View File
@@ -44,7 +44,12 @@ const sqlInjection = "/?id=1'%20OR%20'1'='1"
// core_rule_set_score, and that it has duration_core_rule_set_ms, or with no
// score, that it has none of the three: the Core Rule Set did not inspect the
// request.
func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
func wantCoreRuleSet(
t *testing.T,
line logLine,
score *int,
ruleIDs ...int,
) {
t.Helper()
if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) {
@@ -55,9 +60,12 @@ func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
switch {
case score == nil &&
(line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil):
t.Errorf("log line has core_rule_set_score %v and "+
"duration_core_rule_set_ms %v, want neither",
line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"])
t.Errorf(
"log line has core_rule_set_score %v and "+
"duration_core_rule_set_ms %v, want neither",
line.fields["core_rule_set_score"],
line.fields["duration_core_rule_set_ms"],
)
case score != nil &&
(line.CoreRuleSetScore == nil || *line.CoreRuleSetScore != *score):
t.Errorf("log line has core_rule_set_score %v, want %d",
@@ -82,11 +90,21 @@ func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: tc.mode},
)
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
"exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
wantTags(
t,
s.request(client, sqlInjection, tc.status, tc.action),
"exploitsignature",
)
wantTags(
t,
s.get(client, http.StatusOK, requestlog.ActionForward),
)
})
}
}
@@ -103,7 +121,9 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
}
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(
t *testing.T,
) {
t.Parallel()
for _, attack := range []struct {
@@ -142,10 +162,19 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
{block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden},
{detect, requestlog.ActionForward, http.StatusOK},
} {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: tc.mode},
)
line := s.requestWithHeader(client, attack.path, attack.header,
tc.status, tc.action)
line := s.requestWithHeader(
client,
attack.path,
attack.header,
tc.status,
tc.action,
)
wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...)
}
})
@@ -167,8 +196,12 @@ func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
http.StatusOK, requestlog.ActionForward)
line := s.request(
client,
"/owner/repo/src/branch/main/README.md?display=source",
http.StatusOK,
requestlog.ActionForward,
)
wantCoreRuleSet(t, line, new(0))
}
@@ -188,26 +221,48 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
// exempts, are not inspected.
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
line := s.request(
allowed,
sqlInjection,
http.StatusOK,
requestlog.ActionForward,
)
wantCoreRuleSet(t, line, nil)
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
requestlog.ActionForward)
line = s.request(
client,
"/api/v1/repos?id=1'%20OR%20'1'='1",
http.StatusOK,
requestlog.ActionForward,
)
wantCoreRuleSet(t, line, nil)
// The prefix is matched as rate limit exempt paths are: a path that
// goes up and out of it is inspected.
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
line = s.request(
client,
"/api/../?id=1'%20OR%20'1'='1",
http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet,
)
wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100)
// A request a rule file refuses is not inspected.
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule)
line = s.request(
otherClient,
"/blocked?id=1'%20OR%20'1'='1",
http.StatusForbidden,
requestlog.ActionBlockedByBlockRule,
)
wantCoreRuleSet(t, line, nil)
// With SWW_WAF_MODE off, no request is.
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
line = s.request(
client,
sqlInjection,
http.StatusOK,
requestlog.ActionForward,
)
wantCoreRuleSet(t, line, nil)
}
@@ -221,7 +276,12 @@ func TestAnomalyThreshold(t *testing.T) {
wafMode: block, wafAnomalyThreshold: threshold,
})
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
line := s.request(
client,
sqlInjection,
http.StatusOK,
requestlog.ActionForward,
)
wantCoreRuleSet(t, line, new(5), 942100)
}
@@ -269,9 +329,20 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
}
// By default, the rules are switched off.
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusOK, requestlog.ActionForward)
s, _, _ := startWithClock(
t,
"",
map[string]string{wafMode: block},
)
line, _ := s.requestWithBody(
request.method,
client,
request.path,
request.header,
body,
http.StatusOK,
requestlog.ActionForward,
)
wantCoreRuleSet(t, line, new(0))
// A list given replaces the default.
@@ -279,12 +350,24 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
wafMode: block, wafDisabledRules: "942100",
})
score := 5 * len(request.ruleIDs)
line, _ = s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
line, _ = s.requestWithBody(
request.method,
client,
request.path,
request.header,
body,
http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet,
)
wantCoreRuleSet(t, line, &score, request.ruleIDs...)
// And switches off the rules it lists.
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
line = s.request(
client,
sqlInjection,
http.StatusOK,
requestlog.ActionForward,
)
wantCoreRuleSet(t, line, new(0))
})
}
@@ -354,8 +437,13 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
got := do(t, req)
if got.status != http.StatusOK || string(got.body) != tc.body {
t.Errorf("%s: the app got %d bytes, answered %d, want the %d sent, 200",
tc.name, len(got.body), got.status, len(tc.body))
t.Errorf(
"%s: the app got %d bytes, answered %d, want the %d sent, 200",
tc.name,
len(got.body),
got.status,
len(tc.body),
)
}
line := out.endedLines(t, i+1)[i]
@@ -384,13 +472,19 @@ func TestResponsesAreNotInspected(t *testing.T) {
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
if got.status != http.StatusOK || string(got.body) != page {
t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body)
t.Errorf(
"answered %d with %q, want 200 with the app's page",
got.status,
got.body,
)
}
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
}
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) {
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
t *testing.T,
) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
@@ -408,8 +502,11 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
requestlog.ActionBlockedByCoreRuleSet)
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit",
line.LimitHit, line.Offence)
t.Errorf(
"log line has limit_hit %q and offence %q, want minute_refusals and limit",
line.LimitHit,
line.Offence,
)
}
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
@@ -420,15 +517,30 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
}
metrics := s.scrape()
wantMetric(t, metrics,
`sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3)
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+
`instance="app",status_class="4xx"}`, 3)
wantMetric(
t,
metrics,
`sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`,
3,
)
wantMetric(
t,
metrics,
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`,
3,
)
wantMetric(
t,
metrics,
`sww_requests_total{action="blocked_by_core_rule_set",`+
`instance="app",status_class="4xx"}`,
3,
)
}
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) {
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(
t *testing.T,
) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{
@@ -436,7 +548,12 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
})
for range 3 {
s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
s.request(
client,
sqlInjection,
http.StatusOK,
requestlog.ActionForward,
)
}
s.get(client, http.StatusOK, requestlog.ActionForward)
@@ -447,8 +564,12 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
}
metrics := s.scrape()
wantMetric(t, metrics,
`sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
wantMetric(
t,
metrics,
`sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`,
3,
)
wantNoSeries(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`)
}
@@ -456,9 +577,18 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
s, _, server := startWithClock(
t,
"",
map[string]string{wafMode: block, mode: observe},
)
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
line := s.request(
client,
sqlInjection,
http.StatusOK,
requestlog.ActionForward,
)
wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet)
wantCoreRuleSet(t, line, new(5), 942100)
@@ -508,7 +638,9 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward)
detail := map[string]any{
"rule_ids": []int{942100}, "score": 5, "method": http.MethodGet,
"rule_ids": []int{
942100,
}, "score": 5, "method": http.MethodGet,
"path": sqlInjection,
}
if tc.alertMode != "" {
@@ -527,7 +659,10 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
})
if queue.Suppressed() != 1 {
t.Errorf("%d alerts held back, want the repeat", queue.Suppressed())
t.Errorf(
"%d alerts held back, want the repeat",
queue.Suppressed(),
)
}
})
}
+5 -1
View File
@@ -18,7 +18,11 @@ type countries struct{}
func (countries) name() string { return "countries" }
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
func (countries) check(
_ context.Context,
rq *request,
_ time.Time,
) *verdict {
allowed := rq.h.config.ExclusivelyAllowedCountries
country := rq.line.Country
+79 -21
View File
@@ -52,7 +52,10 @@ func TestCountryLists(t *testing.T) {
calls.Add(1)
})
geojsURL, _ := startGeoJS(t)
env := map[string]string{trustedProxies: trustLocalhost, lookupTimeout: "1h"}
env := map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
}
maps.Copy(env, tc.env)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
@@ -68,14 +71,25 @@ func TestCountryLists(t *testing.T) {
line := out.endedLines(t, i+1)[i]
if line.ASN != sent.asn || line.ASName != sent.asName ||
line.Country != sent.country {
t.Errorf("log line has %q, %q and %q, want %q, %q and %q",
line.ASN, line.ASName, line.Country,
sent.asn, sent.asName, sent.country)
t.Errorf(
"log line has %q, %q and %q, want %q, %q and %q",
line.ASN,
line.ASName,
line.Country,
sent.asn,
sent.asName,
sent.country,
)
}
if slices.Contains(tc.refused, sent.client) {
wantStatus(t, got, http.StatusForbidden)
wantLine(t, line, http.StatusForbidden, requestlog.ActionCountryDenied)
wantLine(
t,
line,
http.StatusForbidden,
requestlog.ActionCountryDenied,
)
} else {
wantStatus(t, got, http.StatusOK)
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
@@ -98,11 +112,19 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
lookupTimeout: "1h", allowedCountries: "de",
})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
line := s.get(
fromKP,
http.StatusForbidden,
requestlog.ActionCountryDenied,
)
wantHeuristic(t, line, "countries",
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
line = s.get(
unplaced,
http.StatusForbidden,
requestlog.ActionCountryDenied,
)
wantHeuristic(t, line, "countries",
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
}
@@ -122,7 +144,13 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
allowedCountries: "de",
})
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
req := newRequest(
t,
http.MethodPost,
addr,
"/",
strings.NewReader("a body"),
)
req.Header.Set(forwardedFor, fromKP)
wantStatus(t, do(t, req), http.StatusForbidden)
@@ -149,7 +177,9 @@ func TestRequestRefusedByCountryIsNotCounted(t *testing.T) {
return
}
answer := []geojsAnswer{{IP: r.URL.Query().Get("ip"), CountryCode: "DE"}}
answer := []geojsAnswer{
{IP: r.URL.Query().Get("ip"), CountryCode: "DE"},
}
err := json.NewEncoder(w).Encode(answer)
if err != nil {
@@ -209,7 +239,10 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, asked := startGeoJS(t)
env := map[string]string{trustedProxies: trustLocalhost, lookupTimeout: "1h"}
env := map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
}
maps.Copy(env, tc.env)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, env)
@@ -230,8 +263,12 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
for _, field := range []string{"asn", "as_name", "country"} {
value, present := line.fields[field]
if !present || value != "" {
t.Errorf("log line for %q has %s %v, want an empty one",
line.ClientIP, field, value)
t.Errorf(
"log line for %q has %s %v, want an empty one",
line.ClientIP,
field,
value,
)
}
}
}
@@ -243,10 +280,16 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK)
waitUntil(func() bool { return slices.Contains(asked(), fromDE) })
waitUntil(
func() bool { return slices.Contains(asked(), fromDE) },
)
if got := asked(); !slices.Equal(got, []string{fromDE}) {
t.Errorf("GeoJS was asked about %v, want %s alone", got, fromDE)
t.Errorf(
"GeoJS was asked about %v, want %s alone",
got,
fromDE,
)
}
})
}
@@ -275,11 +318,16 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
geojsURL, asked := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
allowedCountries: "de",
allowNets: tc.allowNets,
})
addr, out := startProxyWithGeoJS(
t,
app.URL,
geojsURL,
map[string]string{
trustedProxies: trustLocalhost,
allowedCountries: "de",
allowNets: tc.allowNets,
},
)
wantAnswers(t, addr, out, []sentRequest{
{"10.0.0.7", tc.status, tc.action},
@@ -383,9 +431,19 @@ type geojsAnswer struct {
func answerAbout(addr string) geojsAnswer {
switch addr {
case fromDE:
return geojsAnswer{IP: addr, ASN: 64496, ASName: asNameDE, CountryCode: "DE"}
return geojsAnswer{
IP: addr,
ASN: 64496,
ASName: asNameDE,
CountryCode: "DE",
}
case fromKP:
return geojsAnswer{IP: addr, ASN: 64511, ASName: asNameKP, CountryCode: "KP"}
return geojsAnswer{
IP: addr,
ASN: 64511,
ASName: asNameKP,
CountryCode: "KP",
}
}
return geojsAnswer{IP: addr, ASN: 64512, ASName: "Unknown"}
+5 -1
View File
@@ -21,7 +21,11 @@ type crowdSec struct{}
func (crowdSec) name() string { return "crowdsec" }
func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
func (crowdSec) check(
_ context.Context,
rq *request,
now time.Time,
) *verdict {
decision, listed := rq.crowdSecDecisionInForce(now)
if !listed {
return nil
+79 -27
View File
@@ -25,7 +25,9 @@ const (
bouncerKey = "crowdsec-key-0123456789abcdef"
)
func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testing.T) {
func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(
t *testing.T,
) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
@@ -39,44 +41,56 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
// Its first request is refused, and bans it until the decision ends.
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
banned := requestlog.ActionTemporarilyBanned
line := s.get(client, http.StatusForbidden, banned)
wantReputation(t, line, decisionsURL)
if line.BanExpires != expires {
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
t.Errorf("log line has ban_expires %q, want %s",
line.BanExpires, expires)
}
listed := []bans.ReputationHit{{Source: decisionsURL}}
held := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
if len(held) != 1 || held[0].Cause != bans.CauseCrowdSec ||
!held[0].Start.Equal(fetched) || !held[0].Expires.Equal(fetched.Add(4*time.Hour)) ||
!held[0].Start.Equal(fetched) ||
!held[0].Expires.Equal(fetched.Add(4*time.Hour)) ||
held[0].Reason != "CrowdSec's decision for crowdsecurity/ssh-bf" ||
!reflect.DeepEqual(held[0].Notes.Reputation, listed) ||
held[0].Notes.Request.Path != "/" || held[0].Notes.Requests != 1 {
t.Fatalf("bans %+v, want one for crowdsec of four hours, with the list and "+
"the request in its notes", held)
t.Fatalf(
"bans %+v, want one for crowdsec of four hours, with the list and "+
"the request in its notes",
held,
)
}
// The listing raises a reputation_hit alert, and the ban its own.
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 2 || waiting[0].Event != alerts.EventReputationHit ||
waiting[0].Reason != "listed by the CrowdSec decision list" ||
!reflect.DeepEqual(waiting[1], banAlert(alerts.EventBan, fetched, client, held[0],
expires)) {
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban's",
waiting)
!reflect.DeepEqual(
waiting[1],
banAlert(alerts.EventBan, fetched, client, held[0],
expires),
) {
t.Errorf(
"alerts waiting %+v, want a reputation_hit alert, then the ban's",
waiting,
)
}
// Each request while the ban lasts is refused under it, as under any
// ban, and once it has ended the client is let through.
clk.advance(4*time.Hour - time.Second)
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
line = s.get(client, http.StatusForbidden, banned)
wantReputation(t, line)
if line.BanExpires != expires {
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
t.Errorf("log line has ban_expires %q, want %s",
line.BanExpires, expires)
}
clk.advance(time.Second)
@@ -91,8 +105,12 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
`instance="`+alertInstance+`"}`, 1)
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
float64(fetched.Unix()))
wantMetric(
t,
metrics,
"sww_reputation_last_fetch_timestamp_seconds"+labels,
float64(fetched.Unix()),
)
}
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
@@ -105,13 +123,24 @@ func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
`"value": "`+client+`"}]`)
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
line := s.get(
client,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
wantHeuristic(
t,
line,
"crowdsec",
`{"scenario": "crowdsecurity/ssh-bf", `+
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`,
)
wantTags(t, line, "crowdsec")
}
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(
t *testing.T,
) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
@@ -129,24 +158,44 @@ func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T
// Just before its end, the decision bans a client in the netblock, and
// one on an IPv6 address bans the address's group, the /64.
clk.advance(time.Minute - time.Nanosecond)
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(
"198.51.100.7",
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
s.get(
"2001:db8::9",
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
s.get(
"2001:db8::5",
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
// Once it has ended, it bans no other client, and the bans it made end
// with it.
clk.advance(time.Nanosecond)
for _, from := range []string{"198.51.100.8", "198.51.100.7", "2001:db8::5"} {
wantReputation(t, s.get(from, http.StatusOK, requestlog.ActionForward))
wantReputation(
t,
s.get(from, http.StatusOK, requestlog.ActionForward),
)
}
if made := server.Ledger.Made(bans.CauseCrowdSec); made != 2 {
t.Errorf("%d bans made for crowdsec, want 2, on 198.51.100.7/32 and "+
"2001:db8::/64", made)
t.Errorf(
"%d bans made for crowdsec, want 2, on 198.51.100.7/32 and "+
"2001:db8::/64",
made,
)
}
if held := server.Ledger.Bans(netip.MustParsePrefix("2001:db8::/64")); len(held) != 1 {
if held := server.Ledger.Bans(netip.MustParsePrefix("2001:db8::/64")); len(
held,
) != 1 {
t.Errorf("bans of 2001:db8::/64 %+v, want one", held)
}
}
@@ -175,8 +224,11 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
if len(waiting) != 2 || waiting[1].Event != alerts.EventBan ||
waiting[1].Detail["cause"] != bans.CauseCrowdSec ||
waiting[1].Detail["mode"] != observe {
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban alert "+
"marked observe", waiting)
t.Errorf(
"alerts waiting %+v, want a reputation_hit alert, then the ban alert "+
"marked observe",
waiting,
)
}
}
+5 -1
View File
@@ -24,7 +24,11 @@ type headerLimits struct{}
func (headerLimits) name() string { return "header_limits" }
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
func (headerLimits) check(
_ context.Context,
rq *request,
_ time.Time,
) *verdict {
fields, cookies := rq.headerCounts()
if fields <= maxHeaderFields && cookies <= maxCookies {
return nil
+20 -6
View File
@@ -13,7 +13,11 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
// With a limit of one request a minute, any request counted before
// the last one would have it refused.
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
s, _, _ := startWithClock(
t,
"",
map[string]string{rateLimitPerMinute: "1"},
)
const (
healthChecks = 3
@@ -24,9 +28,15 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
got := s.ownEndpointRequest("", http.MethodGet, waf.HealthPath, "",
http.StatusOK)
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
"with %q", got.body, got.header.Get("Content-Type"), contentType)
if string(got.body) != "ok\n" ||
got.header.Get("Content-Type") != contentType {
t.Errorf(
"health endpoint answered %q with Content-Type %q, want ok "+
"with %q",
got.body,
got.header.Get("Content-Type"),
contentType,
)
}
}
@@ -34,8 +44,12 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
for _, line := range s.out.endedLines(t, healthChecks)[:healthChecks] {
if line.ResponseContentType != contentType {
t.Errorf("health check's log line has response_content_type %q, "+
"want %q", line.ResponseContentType, contentType)
t.Errorf(
"health check's log line has response_content_type %q, "+
"want %q",
line.ResponseContentType,
contentType,
)
}
}
}
+4 -1
View File
@@ -117,7 +117,10 @@ func (rq *request) runHeuristics(
// after_answer_heuristic_figures name the heuristic that gave a verdict, and
// give its figures, so that heuristic and heuristic_figures still name the one
// that refused the request. ctx is the request's own context.
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
func (rq *request) runBanningHeuristics(
ctx context.Context,
list []heuristic,
) {
name, v := rq.firstVerdict(ctx, list, rq.h.now())
if v != nil {
rq.line.AfterAnswerHeuristic = name
+29 -7
View File
@@ -60,7 +60,11 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
func TestTableOfClientsHoldsAtMostMaxTrackedClients(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{maxTrackedClients: "2"})
s, _, server := startWithClock(
t,
"",
map[string]string{maxTrackedClients: "2"},
)
// The third client drops the least recently seen, the first, with its
// history.
@@ -70,8 +74,12 @@ func TestTableOfClientsHoldsAtMostMaxTrackedClients(t *testing.T) {
_, held := server.Limiter.Client(netip.MustParsePrefix("192.0.2.1/32"))
if server.Limiter.Len() != 2 || held {
t.Errorf("the table holds %d clients, the first among them: %t; want 2, "+
"without it", server.Limiter.Len(), held)
t.Errorf(
"the table holds %d clients, the first among them: %t; want 2, "+
"without it",
server.Limiter.Len(),
held,
)
}
}
@@ -84,7 +92,10 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
})
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
got := do(
t,
newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")),
)
wantStatus(t, got, http.StatusOK)
out.endedLine(t)
@@ -98,9 +109,19 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
s, _, server := startWithClock(
t,
"",
map[string]string{metricsToken: token},
)
s.ownEndpointRequest("", http.MethodGet, waf.HealthPath, "", http.StatusOK)
s.ownEndpointRequest(
"",
http.MethodGet,
waf.HealthPath,
"",
http.StatusOK,
)
s.ownEndpointRequest("", http.MethodGet, waf.MetricsPath, "",
http.StatusUnauthorized)
s.scrape()
@@ -120,7 +141,8 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
out.endedLine(t)
history := historyOf(t, server, localhost)
if history.Requests != 1 || history.Forwarded != 0 || history.Refused != 0 {
if history.Requests != 1 || history.Forwarded != 0 ||
history.Refused != 0 {
t.Errorf("history counts %d requests, %d forwarded and %d refused, "+
"want 1, 0 and 0", history.Requests, history.Forwarded, history.Refused)
}
+2 -1
View File
@@ -105,5 +105,6 @@ func setLookupHeaders(header http.Header, asn, country string) {
// canBePlaced reports whether a lookup can place addr: private, loopback
// and link-local addresses have no AS number or country.
func canBePlaced(addr netip.Addr) bool {
return !addr.IsPrivate() && !addr.IsLoopback() && !addr.IsLinkLocalUnicast()
return !addr.IsPrivate() && !addr.IsLoopback() &&
!addr.IsLinkLocalUnicast()
}
+98 -29
View File
@@ -24,7 +24,9 @@ type asnAndCountry struct{ asn, asName, country string }
// lookup database.
const fileSource = "file"
func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T) {
func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(
t *testing.T,
) {
t.Parallel()
// The stand-in for GeoJS answers only once released. A request that
@@ -45,7 +47,10 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T)
} {
got := asnAndCountry{line.ASN, line.ASName, line.Country}
if got != (asnAndCountry{}) {
t.Errorf("log line has %+v before GeoJS answered, want nothing", got)
t.Errorf(
"log line has %+v before GeoJS answered, want nothing",
got,
)
}
}
@@ -80,7 +85,12 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T)
// GeoJS was asked about each client once, fromKP after fromDE, whose
// request was under way when fromKP came.
if got := asked(); !slices.Equal(got, []string{fromDE, fromKP}) {
t.Errorf("GeoJS was asked about %v, want %s and %s", got, fromDE, fromKP)
t.Errorf(
"GeoJS was asked about %v, want %s and %s",
got,
fromDE,
fromKP,
)
}
}
@@ -127,7 +137,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
// for GeoJS. The second request goes on at once, and is counted.
clk.advance(2500 * time.Millisecond)
server.GeoJS.Load([]lookup.Answer{{
Client: netip.MustParsePrefix(fromDE + "/32"), ASN: asnDE, ASName: asNameDE,
Client: netip.MustParsePrefix(
fromDE + "/32",
), ASN: asnDE, ASName: asNameDE,
Country: "DE", Answered: clk.Now(), Used: clk.Now(),
}})
wantStatus(t, do(t, requestFromDE()), http.StatusOK)
@@ -140,7 +152,12 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
t.Fatalf("first request: %v", err)
}
wantLine(t, out.endedLines(t, 2)[1], http.StatusOK, requestlog.ActionForward)
wantLine(
t,
out.endedLines(t, 2)[1],
http.StatusOK,
requestlog.ActionForward,
)
// The third request's count holds all three: the first was counted
// when it went on, in the minute under way, and the second's count
@@ -149,22 +166,32 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
wantStatus(t, do(t, requestFromDE()), http.StatusOK)
if got := out.endedLines(t, 3)[2].Counts.Minute; got != 3 {
t.Errorf("the third request's count in the minute is %v, want 3", got)
t.Errorf(
"the third request's count in the minute is %v, want 3",
got,
)
}
}
func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.T) {
func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(
t *testing.T,
) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
addr, out, server, queue := startProxyWithAlerts(
t,
app.URL,
geojsURL,
clk.Now,
map[string]string{
trustedProxies: trustLocalhost,
alertWebhookURL: "https://alerts.example/sww",
rateLimitPerMinute: "1",
})
},
)
s := &sender{t: t, addr: addr, out: out}
// The answer is kept before the requests, so GeoJS is not asked, and
@@ -213,11 +240,17 @@ func TestLookupSourceOffLooksNoClientUp(t *testing.T) {
t.Parallel()
geojsURL, asked := startGeoJS(t)
s, clk, server := startWithClock(t, geojsURL, map[string]string{lookupSource: "off"})
s, clk, server := startWithClock(
t,
geojsURL,
map[string]string{lookupSource: "off"},
)
// Even an answer kept from before is not used.
server.GeoJS.Load([]lookup.Answer{{
Client: netip.MustParsePrefix(fromDE + "/32"), ASN: asnDE, ASName: asNameDE,
Client: netip.MustParsePrefix(
fromDE + "/32",
), ASN: asnDE, ASName: asNameDE,
Country: "DE", Answered: clk.Now(), Used: clk.Now(),
}})
@@ -230,8 +263,13 @@ func TestLookupSourceOffLooksNoClientUp(t *testing.T) {
}
}
if h := historyOf(t, server, fromDE); h.ASN != "" || !h.LookedUp.IsZero() {
t.Errorf("history has %q, looked up at %s, want no lookup", h.ASN, h.LookedUp)
if h := historyOf(t, server, fromDE); h.ASN != "" ||
!h.LookedUp.IsZero() {
t.Errorf(
"history has %q, looked up at %s, want no lookup",
h.ASN,
h.LookedUp,
)
}
if len(asked()) != 0 {
@@ -239,7 +277,9 @@ func TestLookupSourceOffLooksNoClientUp(t *testing.T) {
}
}
func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(
t *testing.T,
) {
t.Parallel()
geojsURL, asked := startGeoJS(t)
@@ -297,7 +337,9 @@ func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
}
}
func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) {
func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(
t *testing.T,
) {
t.Parallel()
var (
@@ -310,15 +352,23 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) {
defer mu.Unlock()
got = append(got, [2][]string{
r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country"),
r.Header.Values(
"X-Client-Asn",
), r.Header.Values("X-Client-Country"),
})
})
geojsURL, _ := startGeoJS(t)
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
addLookupHeaders: on,
})
addr, out, _ := startProxyWithClock(
t,
app.URL,
geojsURL,
time.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
addLookupHeaders: on,
},
)
s := &sender{t: t, addr: addr, out: out}
// Each client sends headers of its own. fromDE's first request waits
@@ -340,7 +390,9 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) {
}
}
func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(t *testing.T) {
func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(
t *testing.T,
) {
t.Parallel()
var (
@@ -352,12 +404,22 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(t *testing.T) {
mu.Lock()
defer mu.Unlock()
asn, country = r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country")
asn, country = r.Header.Values(
"X-Client-Asn",
), r.Header.Values(
"X-Client-Country",
)
})
geojsURL, _ := startGeoJS(t)
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
trustedProxies: trustLocalhost,
})
addr, out, _ := startProxyWithClock(
t,
app.URL,
geojsURL,
time.Now,
map[string]string{
trustedProxies: trustLocalhost,
},
)
s := &sender{t: t, addr: addr, out: out}
s.requestWithHeader(fromDE, "/", clientsOwnLookupHeaders,
@@ -367,8 +429,11 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(t *testing.T) {
defer mu.Unlock()
if asn != nil || country != nil {
t.Errorf("the app was passed X-Client-ASN %v and X-Client-Country %v, want neither",
asn, country)
t.Errorf(
"the app was passed X-Client-ASN %v and X-Client-Country %v, want neither",
asn,
country,
)
}
}
@@ -396,7 +461,11 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
serveFromDE(t, server, http.MethodGet, http.NoBody)
if waited := time.Since(began); waited != timeout {
t.Errorf("the request waited %s for its answer, want %s", waited, timeout)
t.Errorf(
"the request waited %s for its answer, want %s",
waited,
timeout,
)
}
// Without an answer, the client is in no country the list allows.
+75 -19
View File
@@ -32,8 +32,13 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either.
for _, authorization := range []string{bearer, "Bearer ", ""} {
s.ownEndpointRequest(authorization, http.MethodGet, waf.MetricsPath, "",
http.StatusNotFound)
s.ownEndpointRequest(
authorization,
http.MethodGet,
waf.MetricsPath,
"",
http.StatusNotFound,
)
}
}
@@ -60,7 +65,13 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
{http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
{http.MethodPost, waf.HealthPath, "", http.StatusNotFound},
} {
got := s.ownEndpointRequest(tc.authorization, tc.method, tc.path, "", tc.status)
got := s.ownEndpointRequest(
tc.authorization,
tc.method,
tc.path,
"",
tc.status,
)
if tc.status == http.StatusUnauthorized &&
got.header.Get("WWW-Authenticate") != "Bearer" {
@@ -69,7 +80,10 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
}
if tc.status == http.StatusOK &&
!strings.Contains(string(got.body), "# TYPE sww_requests_total counter") {
!strings.Contains(
string(got.body),
"# TYPE sww_requests_total counter",
) {
t.Errorf("the metrics are\n%s", got.body)
}
}
@@ -85,7 +99,10 @@ func TestMetricsCountTheTraffic(t *testing.T) {
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now,
map[string]string{metricsToken: token})
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
got := do(
t,
newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")),
)
wantStatus(t, got, http.StatusOK)
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
out.endedLines(t, 2)
@@ -132,12 +149,26 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
s.get(client, 0, requestlog.ActionClientIsRateLimited)
metrics := s.scrape()
wantMetric(t, metrics,
`sww_requests_total{action="client_denied",instance="app",status_class="none"}`, 1)
wantMetric(
t,
metrics,
`sww_requests_total{action="client_denied",instance="app",status_class="none"}`,
1,
)
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
`kind="requests",window="minute"}`, 1)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
wantMetric(
t,
metrics,
`sww_offences_total{instance="app",kind="limit"}`,
1,
)
wantMetric(
t,
metrics,
`sww_bans_made_total{cause="limit",instance="app"}`,
1,
)
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 0)
@@ -152,8 +183,18 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
metrics = s.scrape()
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
`kind="requests",window="minute"}`, 2)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 2)
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 2)
wantMetric(
t,
metrics,
`sww_offences_total{instance="app",kind="limit"}`,
2,
)
wantMetric(
t,
metrics,
`sww_bans_made_total{cause="limit",instance="app"}`,
2,
)
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
// denied and client.
@@ -201,12 +242,14 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
allowedCountries: "de,fr",
}
geojsURL, _ := startGeoJS(t)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL,
time.Now, env)
// The answers are kept before the requests, so that none waits for
// GeoJS.
server.GeoJS.Load([]lookup.Answer{
keptAnswer(fromKP, "KP"), keptAnswer(fromDE, "DE"), keptAnswer(fromFR, "FR"),
keptAnswer(fromKP, "KP"), keptAnswer(fromDE, "DE"),
keptAnswer(fromFR, "FR"),
})
lines := 0
@@ -214,7 +257,8 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
t.Helper()
for range times {
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc"))
body := strings.NewReader("abc")
req := newRequest(t, http.MethodPost, addr, "/", body)
req.Header.Set(forwardedFor, from)
wantStatus(t, do(t, req), status)
@@ -271,7 +315,9 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
`sww_country_request_bytes_total{country="DE",instance="app"}`)
}
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(
t *testing.T,
) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
@@ -347,7 +393,12 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
wantMetric(t, metrics, `sww_requests_without_geojs_answer_total{instance="app"}`, 1)
wantMetric(
t,
metrics,
`sww_requests_without_geojs_answer_total{instance="app"}`,
1,
)
}
// keptAnswer returns GeoJS's answer that the client at addr is in
@@ -381,8 +432,10 @@ func scrape(t *testing.T, addr string) string {
func (s *sender) scrape() string {
s.t.Helper()
return string(s.ownEndpointRequest(bearer, http.MethodGet, waf.MetricsPath,
"", http.StatusOK).body)
return string(
s.ownEndpointRequest(bearer, http.MethodGet, waf.MetricsPath,
"", http.StatusOK).body,
)
}
// metric returns the value of series in metrics, which are in the
@@ -394,7 +447,10 @@ func metric(t *testing.T, metrics, series string) float64 {
t.Helper()
for line := range strings.Lines(metrics) {
value, found := strings.CutPrefix(strings.TrimSuffix(line, "\n"), series+" ")
value, found := strings.CutPrefix(
strings.TrimSuffix(line, "\n"),
series+" ",
)
if !found {
continue
}
+36 -9
View File
@@ -67,7 +67,12 @@ func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
server.Ledger.Load([]bans.Ban{ban})
if modeValue == observe {
line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward)
line := s.request(
client,
tc.path,
http.StatusOK,
requestlog.ActionForward,
)
wantWouldAction(t, line, tc.want)
return
@@ -93,12 +98,21 @@ func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) {
Cause: bans.CauseAttack,
}})
line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward)
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)
t.Errorf(
"alerts waiting are %+v, want one %s",
waiting,
alerts.EventPermanentBan,
)
}
}
@@ -163,14 +177,22 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
{fromDE, requestlog.ActionClientIsRateLimited},
} {
if !tc.observe {
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
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)
line := s.get(
sent.from,
http.StatusOK,
requestlog.ActionForward,
)
wantWouldAction(t, line, sent.refusal)
}
})
@@ -200,11 +222,16 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
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)
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,
)
}
}
+2 -1
View File
@@ -228,7 +228,8 @@ func newReputation(
// an error in every mode. While the mode is off, a set file is loaded with the
// Core Rule Set only to check it.
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
if cfg.WAFMode == config.WAFModeOff &&
cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
}
+59 -14
View File
@@ -276,7 +276,11 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
// startProxy starts waf, and rproxy in front of the app at appURL, each with
// the settings in env on top of the defaults, and returns where rproxy listens
// and what waf writes.
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
func startProxy(
t *testing.T,
appURL string,
env map[string]string,
) (string, *output) {
t.Helper()
return startProxyWithGeoJS(t, appURL, "", env)
@@ -305,7 +309,13 @@ func startProxyWithClock(
) (string, *output, *waf.Server) {
t.Helper()
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
addr, out, server, _ := startProxyWithAlerts(
t,
appURL,
geojsURL,
now,
env,
)
return addr, out, server
}
@@ -329,7 +339,11 @@ func startProxyWithAlerts(
func serve(t *testing.T, server *waf.Server) {
t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
listener, err := (&net.ListenConfig{}).Listen(
t.Context(),
"tcp",
localhost+":0",
)
if err != nil {
t.Fatalf("listen: %v", err)
}
@@ -361,11 +375,13 @@ func startRProxy(
}
maps.Copy(settings, env)
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
value, ok := settings[name]
cfg, err := config.RProxyFromEnvironment(
func(name string) (string, bool) {
value, ok := settings[name]
return value, ok
})
return value, ok
},
)
if err != nil {
t.Fatalf("settings %v: %v", settings, err)
}
@@ -373,10 +389,18 @@ func startRProxy(
server := rproxy.New(rproxy.Params{
Config: cfg,
RequestLog: io.Discard,
ProcessLog: requestlog.NewProcessLogger(io.Discard, "rproxy", slog.LevelInfo),
ProcessLog: requestlog.NewProcessLogger(
io.Discard,
"rproxy",
slog.LevelInfo,
),
})
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
listener, err := (&net.ListenConfig{}).Listen(
t.Context(),
"tcp",
localhost+":0",
)
if err != nil {
t.Fatalf("listen: %v", err)
}
@@ -403,7 +427,10 @@ func startRProxy(
// env sets it, so that only the tests of the Core Rule Set have their requests
// inspected by it.
func newWAF(
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
t *testing.T,
geojsURL string,
now func() time.Time,
env map[string]string,
) (*waf.Server, *output, *alerts.Queue) {
t.Helper()
@@ -436,7 +463,11 @@ func newWAFWith(
}
out := &output{}
processLog := requestlog.NewProcessLogger(out, cfg.InstanceName, cfg.LogLevel)
processLog := requestlog.NewProcessLogger(
out,
cfg.InstanceName,
cfg.LogLevel,
)
ruleFiles, err := rules.Load(rules.Params{
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
@@ -513,14 +544,28 @@ func readAnswer(res *http.Response) answer {
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
return answer{
status: res.StatusCode,
header: res.Header,
body: body,
err: err,
}
}
// newRequest makes a request for path to addr, where rproxy or waf listens.
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
func newRequest(
t *testing.T,
method, addr, path string,
body io.Reader,
) *http.Request {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+path, body)
req, err := http.NewRequestWithContext(
t.Context(),
method,
"http://"+addr+path,
body,
)
if err != nil {
t.Fatalf("new request: %v", err)
}
+5 -1
View File
@@ -22,7 +22,11 @@ type rateLimits struct{}
func (rateLimits) name() string { return "rate_limits" }
func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
func (rateLimits) check(
_ context.Context,
rq *request,
now time.Time,
) *verdict {
rq.counted = rq.countedByRateLimits()
if !rq.counted {
return nil
+50 -9
View File
@@ -42,7 +42,11 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
{otherClient, otherClient, http.StatusOK},
{"::ffff:" + otherClient, otherClient, http.StatusForbidden},
{"2001:db8::1", "2001:db8::1", http.StatusOK},
{"2001:db8::8000:0:0:1", "2001:db8::8000:0:0:1", http.StatusForbidden},
{
"2001:db8::8000:0:0:1",
"2001:db8::8000:0:0:1",
http.StatusForbidden,
},
{"2001:db8:0:1::1", "2001:db8:0:1::1", http.StatusOK},
}
@@ -53,7 +57,11 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
line := out.endedLines(t, i+1)[i]
if line.ClientIP != sent.logged {
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged)
t.Errorf(
"log line has client_ip %q, want %q",
line.ClientIP,
sent.logged,
)
}
if sent.want == http.StatusOK {
@@ -75,12 +83,25 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
s, _, _ := startWithClock(
t,
"",
map[string]string{rateLimitPerMinute: "1"},
)
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
line := s.get(
client,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
wantHeuristic(
t,
line,
"rate_limits",
`{"window": "minute", "count": 2, "limit": 1}`,
)
}
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
@@ -148,11 +169,26 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
// With a limit of one request a minute, the requests for paths under a
// prefix are not counted, so client's first request for / is within
// the limit; and once client has reached it, they are not refused.
s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
s.request(client, "/favicon.ico?v=2", http.StatusOK, requestlog.ActionForward)
s.request(
client,
"/assets/app.js",
http.StatusOK,
requestlog.ActionForward,
)
s.request(
client,
"/favicon.ico?v=2",
http.StatusOK,
requestlog.ActionForward,
)
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
line := s.request(
client,
"/assets/app.js",
http.StatusOK,
requestlog.ActionForward,
)
if line.LimitHit != "" || line.Counts != (ratelimit.Counts{}) {
t.Errorf("log line has limit_hit %q and counts %+v, want neither",
line.LimitHit, line.Counts)
@@ -214,7 +250,12 @@ func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
// Counted, the second request breaks the limit of one request
// a minute.
s.request(client, sent, http.StatusOK, requestlog.ActionForward)
s.request(client, sent, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.request(
client,
sent,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
})
}
}
+9 -3
View File
@@ -22,7 +22,11 @@ type refusalLimit struct{}
func (refusalLimit) name() string { return "refusal_limit" }
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
func (refusalLimit) check(
_ context.Context,
rq *request,
now time.Time,
) *verdict {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return nil
@@ -42,9 +46,11 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionBlockedByBlockRule, requestlog.ActionBlockedByCoreRuleSet:
case requestlog.ActionBlockedByBlockRule,
requestlog.ActionBlockedByCoreRuleSet:
status = http.StatusForbidden
case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned:
case requestlog.ActionTemporarilyBanned,
requestlog.ActionPermanentlyBanned:
status = cfg.BanResponse
}
+99 -27
View File
@@ -97,8 +97,13 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
for i, r := range tc.refusals[:3] {
line := r.send(s, client)
if line.LimitHit != "" || line.Offence != "" {
t.Errorf("refusal %d: log line has limit_hit %q and offence %q, "+
"want none", i+1, line.LimitHit, line.Offence)
t.Errorf(
"refusal %d: log line has limit_hit %q and offence %q, "+
"want none",
i+1,
line.LimitHit,
line.Offence,
)
}
s.get(client, http.StatusOK, requestlog.ActionForward)
@@ -109,8 +114,12 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
line := tc.refusals[3].send(s, client)
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals "+
"and limit", line.LimitHit, line.Offence)
t.Errorf(
"log line has limit_hit %q and offence %q, want minute_refusals "+
"and limit",
line.LimitHit,
line.Offence,
)
}
s.get(client, http.StatusForbidden, tc.nextAction)
@@ -149,7 +158,10 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
expires := start.Add(time.Hour)
if line.BanExpires != requestlog.FormatTime(expires) {
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
t.Errorf(
"log line has ban_expires %q, want an hour on",
line.BanExpires,
)
}
netblock := netip.MustParsePrefix(client + "/32")
@@ -183,16 +195,30 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
wantOffences := ratelimit.Offences{Limit: 1, BlockedByBlockRule: 3}
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
t.Errorf(
"history counts the offences %+v, want %+v",
offences,
wantOffences,
)
}
metrics := s.scrape()
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
`kind="refusals",window="minute"}`, 1)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
wantMetric(
t,
metrics,
`sww_offences_total{instance="app",kind="limit"}`,
1,
)
wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3)
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
wantMetric(
t,
metrics,
`sww_bans_made_total{cause="limit",instance="app"}`,
1,
)
}
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
@@ -217,8 +243,12 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
blockRule.send(s, fromDE)
got := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32"))
if len(got) != 1 || got[0].Notes.Limit != 2 || got[0].Notes.LimitPercent != nil {
t.Errorf("bans %+v, want one for the limit of 2, without a limit percentage", got)
if len(got) != 1 || got[0].Notes.Limit != 2 ||
got[0].Notes.LimitPercent != nil {
t.Errorf(
"bans %+v, want one for the limit of 2, without a limit percentage",
got,
)
}
}
@@ -230,9 +260,16 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
"/private": http.StatusUnauthorized,
"/forbidden": http.StatusForbidden,
}
s, _, _, queue := startAppWithAlerts(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(statuses[r.URL.Path])
}, map[string]string{errorBurstThreshold: "1", rulesDir: writeRules(t, testRules)})
s, _, _, queue := startAppWithAlerts(
t,
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(statuses[r.URL.Path])
},
map[string]string{
errorBurstThreshold: "1",
rulesDir: writeRules(t, testRules),
},
)
for range 2 {
for path, status := range statuses {
@@ -247,7 +284,12 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
}
// No ban was made, nor its alert raised.
s.request(client, "/missing", http.StatusNotFound, requestlog.ActionForward)
s.request(
client,
"/missing",
http.StatusNotFound,
requestlog.ActionForward,
)
wantAlerts(t, queue)
}
@@ -279,8 +321,11 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
if broken != tc.broken {
t.Errorf("the 31st refusal broke the error burst: %t, want %t",
broken, tc.broken)
t.Errorf(
"the 31st refusal broke the error burst: %t, want %t",
broken,
tc.broken,
)
}
})
}
@@ -300,7 +345,12 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
// A client in SWW_ALLOW_NETS is not refused, and so not counted.
for range 3 {
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
s.request(
allowed,
blockedPath,
http.StatusOK,
requestlog.ActionForward,
)
}
// One the rate limits do not apply to is.
@@ -308,7 +358,10 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
line := blockRule.send(s, exempt)
if line.LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf("log line has limit_hit %q, want minute_refusals", line.LimitHit)
t.Errorf(
"log line has limit_hit %q, want minute_refusals",
line.LimitHit,
)
}
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
@@ -317,7 +370,11 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
t.Parallel()
s, clk, _ := startForErrorBurst(t, "1", map[string]string{limitBanDuration: "1s"})
s, clk, _ := startForErrorBurst(
t,
"1",
map[string]string{limitBanDuration: "1s"},
)
blockRule.send(s, client)
blockRule.send(s, client)
@@ -365,15 +422,26 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
// is the second of the client's, and would have banned it.
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
line := s.request(
client,
blockedPath,
http.StatusOK,
requestlog.ActionForward,
)
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.BanExpires != "" {
t.Errorf("log line has limit_hit %q and ban_expires %q, want minute_refusals "+
"and none", line.LimitHit, line.BanExpires)
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.BanExpires != "" {
t.Errorf(
"log line has limit_hit %q and ban_expires %q, want minute_refusals "+
"and none",
line.LimitHit,
line.BanExpires,
)
}
if got := server.Ledger.Snapshot(); len(got) != 1 || !reflect.DeepEqual(got[0], held) {
if got := server.Ledger.Snapshot(); len(got) != 1 ||
!reflect.DeepEqual(got[0], held) {
t.Errorf("bans %+v, want only the one held", got)
}
@@ -385,12 +453,16 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
notes, _ := waiting[0].Detail["notes"].(bans.Notes)
if notes.Kind != ratelimit.KindRefusals || notes.Count != 2 ||
notes.Request.Status != http.StatusForbidden {
t.Errorf("the alert's notes are %+v, want two refusals, the last answered 403",
notes)
t.Errorf(
"the alert's notes are %+v, want two refusals, the last answered 403",
notes,
)
}
alert := banAlert(alerts.EventBan, start, client, bans.Ban{
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
Netblock: netip.MustParsePrefix(
client + "/32",
), Cause: bans.CauseLimit,
Reason: "refusals per minute over the limit of 1", Notes: notes,
}, requestlog.FormatTime(start.Add(time.Hour)))
alert.Detail["mode"] = observe
+267 -91
View File
@@ -46,7 +46,9 @@ const (
asnURL = "https://lists.example/asn.txt"
)
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(
t *testing.T,
) {
t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
@@ -90,14 +92,23 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for i := range 3 {
line := s.get(from, tc.statuses[i], tc.actions[i])
wantReputation(t, line, dropURL)
wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
line.RateLimitPercentSetting, tc.percent)
wantPercent(
t,
"rate_limit_percent",
line.RateLimitPercent,
line.RateLimitPercentSetting,
tc.percent,
)
// A request refused for the list is not counted.
counted := line.fields["counts"] != nil
if counted != (tc.actions[i] != denied) {
t.Errorf("request from %s counted %t, logged %s", from, counted,
tc.actions[i])
t.Errorf(
"request from %s counted %t, logged %s",
from,
counted,
tc.actions[i],
)
}
}
}
@@ -112,14 +123,17 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
}
// A refusal for the list makes no ban.
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
if held := server.Ledger.Snapshot(); tc.action == actionDeny &&
len(held) != 0 {
t.Errorf("bans %+v, want none", held)
}
})
}
}
func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) {
func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
@@ -163,7 +177,11 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
s, server, _ := startWithLookups(t, tc.env)
tc.load(t, server)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
line := s.get(
client,
http.StatusForbidden,
requestlog.ActionClientDenied,
)
wantHeuristic(t, line, tc.heuristic, tc.figures)
})
}
@@ -180,12 +198,17 @@ func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) {
// fromKP's country refuses it before the list is looked at, and fromDE,
// in SWW_ALLOW_NETS, is not checked at all: neither is noted, nor
// alerted.
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
wantReputation(
t,
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
)
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
wantAlerts(t, queue)
}
func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(
t *testing.T,
) {
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
@@ -203,7 +226,9 @@ func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
}
}
func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) {
func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
@@ -238,13 +263,19 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
tc.want)
if line.LimitHit != tc.limitHit {
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
t.Errorf(
"log line has limit_hit %q, want %q",
line.LimitHit,
tc.limitHit,
)
}
})
}
}
func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T) {
func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(
t *testing.T,
) {
t.Parallel()
const (
@@ -288,7 +319,11 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
maps.Copy(env, tc.env)
s, server, _ := startWithLookups(t, env)
loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}})
loadLists(
t,
server,
map[string][]string{asnURL: {"# by AS number", tc.file}},
)
line := s.uploadFrom(fromDE)
wantPercent(t, "rate_limit_percent",
@@ -313,13 +348,21 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
blocklistAction: actionLog,
metricsToken: token,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE}, torURL: {fromDE}})
loadLists(
t,
server,
map[string][]string{dropURL: {fromDE}, torURL: {fromDE}},
)
// The second request's alerts are repeats, which the cooldown holds
// back.
for range 2 {
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
dropURL, torURL)
wantReputation(
t,
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
dropURL,
torURL,
)
}
hit := func(source string) alerts.Alert {
@@ -340,7 +383,10 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
wantAlerts(t, queue, hit(dropURL), hit(torURL))
if queue.Suppressed() != 2 {
t.Errorf("%d alerts held back, want the second request's 2", queue.Suppressed())
t.Errorf(
"%d alerts held back, want the second request's 2",
queue.Suppressed(),
)
}
// Each list's hits, none of its fetches failed, and when its copy was
@@ -360,8 +406,12 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
}
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
want.fetched)
wantMetric(
t,
metrics,
"sww_reputation_last_fetch_timestamp_seconds"+labels,
want.fetched,
)
}
}
@@ -418,7 +468,8 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
dnsblResolver: noResolver, reputationAction: tc.action,
})
listedBy := map[string][]string{
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone}, unplaced: nil,
fromDE: {dnsblZone, otherZone}, fromKP: {otherZone},
unplaced: nil,
}
loadVerdicts(server, listedBy)
@@ -427,14 +478,19 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
line := s.get(from, tc.statuses[i], tc.actions[i])
// In the order SWW_DNSBL_ZONES names them.
wantReputation(t, line, listedBy[from]...)
wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
line.RateLimitPercentSetting, tc.percent)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.percent)
// A request refused for the verdict is not counted.
counted := line.fields["counts"] != nil
if counted != (tc.actions[i] != denied) {
t.Errorf("request from %s counted %t, logged %s", from, counted,
tc.actions[i])
t.Errorf(
"request from %s counted %t, logged %s",
from,
counted,
tc.actions[i],
)
}
}
}
@@ -450,7 +506,8 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
// A refusal for the verdict makes no ban, and every client had its
// verdicts, so no zone was asked.
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
if held := server.Ledger.Snapshot(); tc.action == actionDeny &&
len(held) != 0 {
t.Errorf("bans %+v, want none", held)
}
@@ -469,18 +526,27 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
reputationAction: actionDeny, allowNets: fromDE,
})
loadLists(t, server, map[string][]string{dropURL: {fromKP}})
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
loadVerdicts(
server,
map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}},
)
// The blocklist refuses fromKP before its verdict is looked at, and
// fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
// for the zone, nor alerted, nor asked about.
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
dropURL)
wantReputation(
t,
s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
dropURL,
)
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Detail["source"] != dropURL {
t.Errorf("alerts waiting %+v, want the blocklist's reputation_hit alone", waiting)
t.Errorf(
"alerts waiting %+v, want the blocklist's reputation_hit alone",
waiting,
)
}
if queries := server.DNSBL.Queries(dnsblZone); queries != 0 {
@@ -522,13 +588,17 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
s, server, _ := startWithLookups(t, tc.env)
tc.load(server)
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
wantTags(
t,
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
tc.want...)
})
}
}
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
t *testing.T,
) {
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
@@ -547,7 +617,9 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
}
}
func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T) {
func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
@@ -563,27 +635,37 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
{actionLog, limitQuarter, "25 from " + reputationAction},
{actionLog, actionLog, none},
} {
t.Run(tc.blocklistAction+" "+tc.reputationAction, func(t *testing.T) {
t.Parallel()
t.Run(
tc.blocklistAction+" "+tc.reputationAction,
func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, blocklistAction: tc.blocklistAction,
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: tc.reputationAction,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
s, server, _ := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, blocklistAction: tc.blocklistAction,
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: tc.reputationAction,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
loadVerdicts(
server,
map[string][]string{fromDE: {dnsblZone}},
)
// Named by the blocklist, then by the zone.
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantReputation(t, line, dropURL, dnsblZone)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want)
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.want)
})
// Named by the blocklist, then by the zone.
line := s.get(
fromDE,
http.StatusOK,
requestlog.ActionForward,
)
wantReputation(t, line, dropURL, dnsblZone)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want)
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.want)
},
)
}
}
@@ -603,8 +685,12 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
// The second request's alerts are repeats, which the cooldown holds
// back.
for range 2 {
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
dnsblZone, otherZone)
wantReputation(
t,
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
dnsblZone,
otherZone,
)
}
hit := func(zone string) alerts.Alert {
@@ -625,7 +711,10 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
wantAlerts(t, queue, hit(dnsblZone), hit(otherZone))
if queue.Suppressed() != 2 {
t.Errorf("%d alerts held back, want the second request's 2", queue.Suppressed())
t.Errorf(
"%d alerts held back, want the second request's 2",
queue.Suppressed(),
)
}
// Each zone's hits, and its queries and their failures, none, since
@@ -656,12 +745,19 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
})
loadVerdicts(server, map[string][]string{fromDE: {keyed}, unplaced: nil})
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), masked)
wantReputation(
t,
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
masked,
)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Detail["source"] != masked {
t.Errorf("alerts waiting %+v, want a reputation_hit alert from %s", waiting,
masked)
t.Errorf(
"alerts waiting %+v, want a reputation_hit alert from %s",
waiting,
masked,
)
}
metrics := s.scrape()
@@ -677,7 +773,9 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
}
}
func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(
t *testing.T,
) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
@@ -693,7 +791,11 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
// request finds the query under way, or the zone left alone after it
// failed, since nothing answers at noResolver.
for range 2 {
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward), otherZone)
wantReputation(
t,
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
otherZone,
)
}
if queries := server.DNSBL.Queries(dnsblZone); queries != 1 {
@@ -725,17 +827,23 @@ const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
type abuseIPDBStandIn struct{}
// RoundTrip answers req with the score 100.
func (abuseIPDBStandIn) RoundTrip(req *http.Request) (*http.Response, error) {
func (abuseIPDBStandIn) RoundTrip(
req *http.Request,
) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(`{"data":{"abuseConfidenceScore":100}}`)),
Request: req,
Body: io.NopCloser(
strings.NewReader(`{"data":{"abuseConfidenceScore":100}}`),
),
Request: req,
}, nil
}
func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T) {
func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(
t *testing.T,
) {
t.Parallel()
forward := requestlog.ActionForward
@@ -766,7 +874,9 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T
wantAbuseIPDBChecks(t, server, 1)
}
func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing.T) {
func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(
t *testing.T,
) {
t.Parallel()
forward := requestlog.ActionForward
@@ -775,7 +885,10 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing
// of its own.
var addresses []string
for i := 1; i < 16; i++ {
addresses = append(addresses, fmt.Sprintf("2001:db8:1:2:%x::9", i<<12))
addresses = append(
addresses,
fmt.Sprintf("2001:db8:1:2:%x::9", i<<12),
)
}
s, clk, server := startWithClock(t, "", map[string]string{
@@ -789,7 +902,11 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing
s.get(addresses[0], http.StatusOK, forward)
}
s.get(addresses[0], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(
addresses[0],
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
clk.advance(time.Hour)
// Once the ban has ended, which set its counters back to zero, a
@@ -808,7 +925,9 @@ const (
blockedPath = "/blocked"
)
func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T) {
func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
@@ -851,7 +970,11 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
wantAbuseIPDBChecks(t, server, 0)
if got := historyOf(t, server, client).Offences; got != tc.want {
t.Errorf("history counts the offences %+v, want %+v", got, tc.want)
t.Errorf(
"history counts the offences %+v, want %+v",
got,
tc.want,
)
}
// Its next request, once any ban for a clear sign of attack
@@ -863,7 +986,9 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
}
}
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
t *testing.T,
) {
t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
@@ -911,7 +1036,11 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
// A request refused for the score is not counted.
counted := line.fields["counts"] != nil
if counted != (tc.actions[i] != denied) {
t.Errorf("request counted %t, logged %s", counted, tc.actions[i])
t.Errorf(
"request counted %t, logged %s",
counted,
tc.actions[i],
)
}
}
@@ -925,7 +1054,8 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
}
// A refusal for the score makes no ban.
if held := server.Ledger.Snapshot(); tc.action == actionDeny && len(held) != 0 {
if held := server.Ledger.Snapshot(); tc.action == actionDeny &&
len(held) != 0 {
t.Errorf("bans %+v, want none", held)
}
})
@@ -944,8 +1074,11 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
// The second request's alert is a repeat, which the cooldown holds back.
for range 2 {
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
abuseipdb)
wantReputation(
t,
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
abuseipdb,
)
}
// The alert is made as a DNSBL zone's is, with the score besides.
@@ -954,8 +1087,12 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
waiting[0].Reason != "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE" ||
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
queue.Suppressed() != 1 {
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
"with the score 90, and 1", waiting, queue.Suppressed())
t.Errorf(
"alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
"with the score 90, and 1",
waiting,
queue.Suppressed(),
)
}
// The hits, and the checks, none, since no client committed an
@@ -966,7 +1103,12 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "sww_reputation_daily_budget_remaining"+labels, 900)
wantMetric(
t,
metrics,
"sww_reputation_daily_budget_remaining"+labels,
900,
)
}
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
@@ -996,7 +1138,8 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
score := int64(90)
listed := []bans.ReputationHit{
{Source: dropURL}, {Source: dnsblZone}, {Source: abuseipdb, Score: &score},
{Source: dropURL}, {Source: dnsblZone},
{Source: abuseipdb, Score: &score},
}
for _, tc := range []struct {
@@ -1024,8 +1167,12 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
// Every source lists client, and none otherClient, whose score is
// under SWW_ABUSEIPDB_MIN_SCORE, 75 by default.
loadLists(t, server, map[string][]string{dropURL: {client}})
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
loadScores(server, map[string]int64{client: score, otherClient: 74})
loadVerdicts(server, map[string][]string{
client: {dnsblZone}, otherClient: nil,
})
loadScores(server, map[string]int64{
client: score, otherClient: 74,
})
for _, banned := range []struct {
from string
@@ -1033,10 +1180,20 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
}{{client, listed}, {otherClient, nil}} {
tc.ban(s, banned.from)
held := server.Ledger.Bans(netip.MustParsePrefix(banned.from + "/32"))
if len(held) != 1 || !reflect.DeepEqual(held[0].Notes.Reputation, banned.want) {
t.Errorf("bans of %s %+v, want one whose notes have the reputation %+v",
banned.from, held, banned.want)
held := server.Ledger.Bans(
netip.MustParsePrefix(banned.from + "/32"),
)
if len(held) != 1 ||
!reflect.DeepEqual(
held[0].Notes.Reputation,
banned.want,
) {
t.Errorf(
"bans of %s %+v, want one whose notes have the reputation %+v",
banned.from,
held,
banned.want,
)
}
}
@@ -1050,9 +1207,13 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
}
}
if want := [][]bans.ReputationHit{listed, nil}; !reflect.DeepEqual(alerted, want) {
t.Errorf("the ban alerts' notes have the reputation %+v, want %+v",
alerted, want)
want := [][]bans.ReputationHit{listed, nil}
if !reflect.DeepEqual(alerted, want) {
t.Errorf(
"the ban alerts' notes have the reputation %+v, want %+v",
alerted,
want,
)
}
})
}
@@ -1076,13 +1237,20 @@ func verdictsFetched() time.Time {
// client from each zone listedBy gives for it, and one that does not from
// each other zone.
func loadVerdicts(server *waf.Server, listedBy map[string][]string) {
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
verdicts := make(
[]reputation.Verdict,
0,
len(listedBy)*len(server.DNSBL.Zones()),
)
for client, zones := range listedBy {
for _, zone := range server.DNSBL.Zones() {
verdicts = append(verdicts, reputation.Verdict{
Zone: zone, Client: netip.MustParseAddr(client),
Listed: slices.Contains(zones, zone), Fetched: verdictsFetched(),
Listed: slices.Contains(
zones,
zone,
), Fetched: verdictsFetched(),
})
}
}
@@ -1117,7 +1285,11 @@ func wantAbuseIPDBChecks(t *testing.T, server *waf.Server, want int) {
// loadLists puts copies of lists into server's lists, by URL, each with
// its lines, fetched at listsFetched, as lists.json would at start.
func loadLists(t *testing.T, server *waf.Server, copies map[string][]string) {
func loadLists(
t *testing.T,
server *waf.Server,
copies map[string][]string,
) {
t.Helper()
lists := make([]reputation.List, 0, len(copies))
@@ -1139,6 +1311,10 @@ func wantReputation(t *testing.T, line logLine, want ...string) {
t.Helper()
if !slices.Equal(line.Reputation, want) {
t.Errorf("log line has reputation %v, want %v", line.Reputation, want)
t.Errorf(
"log line has reputation %v, want %v",
line.Reputation,
want,
)
}
}
+42 -23
View File
@@ -90,7 +90,10 @@ type request struct {
// with what is known of the request. isQuestion says whether r is a question,
// whose headers give the request's id and scheme.
func (h *handler) startRequest(
w http.ResponseWriter, r *http.Request, client, peer netip.Addr, isQuestion bool,
w http.ResponseWriter,
r *http.Request,
client, peer netip.Addr,
isQuestion bool,
) *request {
h.metrics.RequestStarted()
@@ -102,23 +105,26 @@ func (h *handler) startRequest(
client: client,
requestURI: r.URL.RequestURI(),
line: requestlog.Line{
Time: requestlog.FormatTime(start),
Instance: h.config.InstanceName,
ClientIP: client.String(),
Method: r.Method,
Scheme: scheme(r, isQuestion),
Host: r.Host,
Path: rules.Path(r),
Query: r.URL.RawQuery,
Protocol: r.Proto,
Referer: r.Referer(),
UserAgent: r.UserAgent(),
RequestID: requestID(r, isQuestion),
PeerIP: peer.String(),
ForwardedFor: strings.Join(forwardedFor, ", "),
ClientGroup: h.clientGroup(client).String(),
ContentType: r.Header.Get("Content-Type"),
RequestHeaders: requestHeaders(r, h.config.LogRequestHeaders),
Time: requestlog.FormatTime(start),
Instance: h.config.InstanceName,
ClientIP: client.String(),
Method: r.Method,
Scheme: scheme(r, isQuestion),
Host: r.Host,
Path: rules.Path(r),
Query: r.URL.RawQuery,
Protocol: r.Proto,
Referer: r.Referer(),
UserAgent: r.UserAgent(),
RequestID: requestID(r, isQuestion),
PeerIP: peer.String(),
ForwardedFor: strings.Join(forwardedFor, ", "),
ClientGroup: h.clientGroup(client).String(),
ContentType: r.Header.Get("Content-Type"),
RequestHeaders: requestHeaders(
r,
h.config.LogRequestHeaders,
),
HasAuthorization: len(r.Header.Values("Authorization")) > 0,
HasCookie: len(r.Header.Values("Cookie")) > 0,
Action: requestlog.ActionForward,
@@ -191,7 +197,10 @@ func (rq *request) check(ctx context.Context) *refusal {
action == requestlog.ActionBlockedByCoreRuleSet:
return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionRequestHeaderTooLarge:
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
return &refusal{
status: http.StatusRequestHeaderFieldsTooLarge,
action: action,
}
default:
return rq.banResponse(action)
}
@@ -281,7 +290,11 @@ func (rq *request) finish() {
func (rq *request) writeLine() {
err := requestlog.Write(rq.h.requestLog, &rq.line)
if err != nil {
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
rq.h.processLog.Error(
"writing the request log failed",
"error",
err.Error(),
)
}
}
@@ -315,7 +328,11 @@ func (rq *request) addToHistory() {
BlockedByCoreRuleSet: rq.blockedByCoreRuleSet,
}
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
rq.h.limiter.AddToHistory(
rq.h.clientGroup(rq.client),
rq.h.now(),
request,
)
rq.h.metrics.Offences(request)
answer, found := rq.answerAtTheEnd()
@@ -353,8 +370,10 @@ func (rq *request) countAnomalies() {
func anomalyThresholdsSet(cfg *config.Config) bool {
off := anomaly.Thresholds{}
return cfg.AnomalyClient != off || cfg.AnomalyNet != off || cfg.AnomalyASN != off ||
cfg.AnomalyTotal != off || cfg.AnomalyWatch != off
return cfg.AnomalyClient != off || cfg.AnomalyNet != off ||
cfg.AnomalyASN != off ||
cfg.AnomalyTotal != off ||
cfg.AnomalyWatch != off
}
// answerAtTheEnd returns, for a client that was looked up, the lookup's
+80 -24
View File
@@ -92,8 +92,10 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
}
if strings.Contains(out.text(), "secret") {
t.Errorf("a value of Authorization, Cookie or Set-Cookie is logged:\n%s",
out.text())
t.Errorf(
"a value of Authorization, Cookie or Set-Cookie is logged:\n%s",
out.text(),
)
}
}
@@ -102,7 +104,10 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
func wantFullLine(t *testing.T, line logLine) {
t.Helper()
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
headers := map[string]string{
"accept": "text/html",
"x-custom": "one, two",
}
want := withTimings(line, requestlog.Line{
Type: requestType, WAFLine: requestlog.WAFLineReport,
@@ -120,8 +125,13 @@ func wantFullLine(t *testing.T, line logLine) {
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8,
},
})
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "from-traefik" {
t.Errorf("log line\n%+v\nwant\n%+v, with a new request id", line.Line, want)
if !reflect.DeepEqual(line.Line, want) ||
line.RequestID == "from-traefik" {
t.Errorf(
"log line\n%+v\nwant\n%+v, with a new request id",
line.Line,
want,
)
}
}
@@ -140,7 +150,11 @@ func wantBareLine(t *testing.T, line logLine) {
Action: requestlog.ActionForward,
})
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" {
t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want)
t.Errorf(
"log line\n%+v\nwant\n%+v, with a request id",
line.Line,
want,
)
}
for _, name := range []string{
@@ -163,7 +177,9 @@ func withTimings(line logLine, want requestlog.Line) requestlog.Line {
return want
}
func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(
t *testing.T,
) {
t.Parallel()
const hasAuthorization, hasCookie = "has_authorization", "has_cookie"
@@ -186,8 +202,13 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
_, otherPresent := line.fields[tc.other]
if line.fields[tc.field] != true || otherPresent {
t.Errorf("log line has %s %v and %s %v, want true and none",
tc.field, line.fields[tc.field], tc.other, line.fields[tc.other])
t.Errorf(
"log line has %s %v and %s %v, want true and none",
tc.field,
line.fields[tc.field],
tc.other,
line.fields[tc.other],
)
}
})
}
@@ -206,10 +227,16 @@ func TestTimingsAreInOrder(t *testing.T) {
_, _ = io.WriteString(w, "done")
})
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, map[string]string{
trustedProxies: trustLocalhost,
denyNets: denied,
})
addr, out, server := startProxyWithClock(
t,
app.URL,
"",
time.Now,
map[string]string{
trustedProxies: trustLocalhost,
denyNets: denied,
},
)
// Each log line is waited for before the next request, so that the
// lines are in order.
@@ -243,8 +270,12 @@ func TestTimingsAreInOrder(t *testing.T) {
// The checks end before the request is handed to the app.
if checks+upstream > total {
t.Errorf("timings in microseconds: total %d, checks %d, upstream total %d",
total, checks, upstream)
t.Errorf(
"timings in microseconds: total %d, checks %d, upstream total %d",
total,
checks,
upstream,
)
}
if *refused.DurationChecksMS > refused.DurationTotalMS {
@@ -269,7 +300,12 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("log line of %s has timings %v, want %v", line.Path, got, want)
t.Errorf(
"log line of %s has timings %v, want %v",
line.Path,
got,
want,
)
}
}
@@ -280,23 +316,43 @@ func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper()
if line.Heuristic != heuristic ||
!reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s",
line.Heuristic, line.HeuristicFigures, heuristic, figures)
!reflect.DeepEqual(
line.HeuristicFigures,
decodeFigures(t, figures),
) {
t.Errorf(
"log line has heuristic %q with heuristic_figures %v, want %q with %s",
line.Heuristic,
line.HeuristicFigures,
heuristic,
figures,
)
}
}
// wantAfterAnswerHeuristic checks after_answer_heuristic and
// after_answer_heuristic_figures as wantHeuristic checks heuristic and
// heuristic_figures.
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
func wantAfterAnswerHeuristic(
t *testing.T,
line logLine,
heuristic, figures string,
) {
t.Helper()
if line.AfterAnswerHeuristic != heuristic ||
!reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has after_answer_heuristic %q with "+
"after_answer_heuristic_figures %v, want %q with %s",
line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
!reflect.DeepEqual(
line.AfterAnswerHeuristicFigures,
decodeFigures(t, figures),
) {
t.Errorf(
"log line has after_answer_heuristic %q with "+
"after_answer_heuristic_figures %v, want %q with %s",
line.AfterAnswerHeuristic,
line.AfterAnswerHeuristicFigures,
heuristic,
figures,
)
}
}
+13 -3
View File
@@ -20,12 +20,20 @@ type routeWatchTags struct{}
func (routeWatchTags) name() string { return "routewatch_tags" }
func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict {
func (routeWatchTags) check(
ctx context.Context,
rq *request,
_ time.Time,
) *verdict {
if rq.h.routeWatch == nil {
return nil
}
route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client)
route, missing := rq.h.routeWatch.Route(
ctx,
rq.h.clientGroup(rq.client),
rq.client,
)
if missing != "" {
rq.line.RouteWatchTagsMissing = string(missing)
@@ -35,7 +43,9 @@ func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verd
rq.addTag(config.PrefixTagPrefix + route.Netblock.String())
if route.ASN != 0 {
rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10))
rq.addTag(
config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10),
)
}
return nil
+47 -12
View File
@@ -67,13 +67,21 @@ func waitForRouteWatch(
deadline := time.Now().Add(5 * time.Second)
for {
_, missing := server.RouteWatch.Route(context.Background(), client, addr)
_, missing := server.RouteWatch.Route(
context.Background(),
client,
addr,
)
if missing == "" {
return
}
if time.Now().After(deadline) {
t.Fatalf("routewatch's answer about %s not kept: %q", from, missing)
t.Fatalf(
"routewatch's answer about %s not kept: %q",
from,
missing,
)
}
time.Sleep(time.Millisecond)
@@ -92,8 +100,14 @@ func wantTagsMissing(t *testing.T, line logLine, want string) {
func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}})
s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url})
url := startRouteWatch(
t,
map[string]standInRoute{fromDE: {prefixDE, 64496}},
)
s, server, _ := startWithLookups(
t,
map[string]string{routeWatchURL: url},
)
waitForRouteWatch(t, server, fromDE, 32)
@@ -105,14 +119,21 @@ func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}})
url := startRouteWatch(
t,
map[string]standInRoute{fromKP: {prefixKP, 64511}},
)
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, denyNets: fromKP,
})
waitForRouteWatch(t, server, fromKP, 32)
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
line := s.get(
fromKP,
http.StatusForbidden,
requestlog.ActionClientDenied,
)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`)
wantTags(t, line, "asn=64511", "prefix="+prefixKP)
}
@@ -134,7 +155,11 @@ func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) {
func TestUnsetRouteWatchChangesNothing(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{lookupSource: off})
s, _, server := startWithClock(
t,
"",
map[string]string{lookupSource: off},
)
if server.RouteWatch != nil {
t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset")
@@ -159,8 +184,11 @@ func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
lookupSource: off, routeWatchURL: "http://224.0.0.1",
})
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"no_answer_yet")
wantTagsMissing(
t,
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"no_answer_yet",
)
deadline := time.Now().Add(5 * time.Second)
@@ -218,15 +246,22 @@ func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) {
waitForRouteWatch(t, server, fromKP, 32)
waitForRouteWatch(t, server, fromDE, 32)
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "prefix=`+prefixKP+`"}`)
wantHeuristic(
t,
s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny",
`{"tag": "prefix=`+prefixKP+`"}`,
)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}})
url := startRouteWatch(
t,
map[string]standInRoute{fromV6: {prefixV6, 64496}},
)
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2",
tagLimitPercent: "prefix=" + prefixV6 + ":50",
+13 -3
View File
@@ -26,7 +26,11 @@ type ruleFiles struct{}
func (ruleFiles) name() string { return "rule_files" }
func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
func (ruleFiles) check(
_ context.Context,
rq *request,
now time.Time,
) *verdict {
matched := rq.h.rules.Match(rq.in)
for _, rule := range matched {
@@ -48,12 +52,18 @@ func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
case rules.ActionBlock:
rq.blockedByBlockRule = true
return &verdict{action: requestlog.ActionBlockedByBlockRule, figures: figures}
return &verdict{
action: requestlog.ActionBlockedByBlockRule,
figures: figures,
}
case rules.ActionBan:
rq.attack = true
return &verdict{
action: rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target}),
action: rq.banForAttack(
now,
bans.Notes{RuleID: last.ID, Target: last.Target},
),
figures: figures,
}
default:
+115 -25
View File
@@ -48,7 +48,10 @@ func TestEachRuleAction(t *testing.T) {
wantRuleIDs(t, line, "noted", "probe")
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires)
t.Errorf(
"log line has ban_expires %q, want seven days on",
line.BanExpires,
)
}
netblock := netip.MustParsePrefix(client + "/32")
@@ -83,15 +86,26 @@ func TestEachRuleAction(t *testing.T) {
// against the rules, and makes the ban permanent.
clk.advance(time.Hour)
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
line = s.get(
client,
http.StatusTooManyRequests,
requestlog.ActionPermanentlyBanned,
)
wantRuleIDs(t, line)
if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
t.Errorf(
"log line has ban_expires %q, want permanent",
line.BanExpires,
)
}
clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
s.get(
client,
http.StatusTooManyRequests,
requestlog.ActionPermanentlyBanned,
)
}
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
@@ -101,8 +115,11 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
})
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
"exploitsignature")
wantTags(
t,
s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
"exploitsignature",
)
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule), "exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
@@ -117,11 +134,21 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
line := s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule)
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
wantHeuristic(
t,
line,
"rule_files",
`{"rule_id": "blocked", "rule_action": "block"}`,
)
line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
wantHeuristic(
t,
line,
"rule_files",
`{"rule_id": "probe", "rule_action": "ban"}`,
)
}
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
@@ -136,7 +163,10 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
line := s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
t.Errorf(
"log line has ban_expires %q, want an hour on",
line.BanExpires,
)
}
// Once that ban has run out without a request, the client is served,
@@ -147,7 +177,10 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionPermanentlyBanned)
if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
t.Errorf(
"log line has ban_expires %q, want permanent",
line.BanExpires,
)
}
}
@@ -167,7 +200,12 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
})
// A client in SWW_ALLOW_NETS is not checked.
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
line := s.request(
allowed,
"/.env",
http.StatusOK,
requestlog.ActionForward,
)
wantRuleIDs(t, line)
// A probe over the rate limit breaks the limit before any rule sees
@@ -184,7 +222,12 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
// A client the rate limits do not apply to is still checked.
s.get(exempt, http.StatusOK, requestlog.ActionForward)
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.request(
exempt,
"/.env",
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
}
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
@@ -195,11 +238,21 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
mode: observe,
})
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
line := s.request(
client,
"/blocked",
http.StatusOK,
requestlog.ActionForward,
)
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
wantRuleIDs(t, line, "blocked")
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
line = s.request(
client,
"/.env",
http.StatusOK,
requestlog.ActionForward,
)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line, "probe")
@@ -226,22 +279,55 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule)
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.request(
client,
"/.env",
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
metrics := s.scrape()
wantMetric(t, metrics,
`sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
wantMetric(t, metrics,
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
wantMetric(
t,
metrics,
`sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`,
1,
)
wantMetric(
t,
metrics,
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`,
1,
)
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_block_rule",`+
`instance="app",status_class="4xx"}`, 1)
wantMetric(
t,
metrics,
`sww_requests_total{action="blocked_by_block_rule",`+
`instance="app",status_class="4xx"}`,
1,
)
wantMetric(t, metrics,
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 1)
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
wantMetric(
t,
metrics,
`sww_offences_total{instance="app",kind="attack"}`,
1,
)
wantMetric(
t,
metrics,
`sww_bans_made_total{cause="attack",instance="app"}`,
1,
)
wantMetric(
t,
metrics,
`sww_bans_made_total{cause="limit",instance="app"}`,
0,
)
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
}
@@ -252,7 +338,11 @@ func writeRules(t *testing.T, content string) string {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "test.rules"), []byte(content), 0o600)
err := os.WriteFile(
filepath.Join(dir, "test.rules"),
[]byte(content),
0o600,
)
if err != nil {
t.Fatalf("write the rule file: %v", err)
}
+33 -6
View File
@@ -68,7 +68,11 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
{
unlistedAddr,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
},
})
}
@@ -87,7 +91,13 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
tagDeny: denyKP,
})
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
req := newRequest(
t,
http.MethodPost,
addr,
"/",
strings.NewReader("a body"),
)
req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusForbidden)
@@ -108,7 +118,11 @@ func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
line := s.get(
client,
http.StatusForbidden,
requestlog.ActionClientDenied,
)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
}
@@ -128,7 +142,11 @@ func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
{
unlistedAddr,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
},
})
}
@@ -152,7 +170,11 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
{
unlistedAddr,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
},
{fromKP, http.StatusForbidden, requestlog.ActionClientDenied},
})
}
@@ -168,7 +190,12 @@ type sentRequest struct {
// wantAnswers sends requests to sww at addr one after another and
// checks each one's answer and log line. They must be the first requests
// sww is sent, since the log lines are matched to them in order.
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
func wantAnswers(
t *testing.T,
addr string,
out *output,
requests []sentRequest,
) {
t.Helper()
for i, sent := range requests {
+56 -13
View File
@@ -14,7 +14,11 @@ func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
// in capitals.
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
line := s.get(
fromKP,
http.StatusForbidden,
requestlog.ActionClientDenied,
)
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
wantTags(t, line, "asn=64511", "country=KP")
@@ -26,8 +30,12 @@ func TestTagDenyRefusesAnASNTag(t *testing.T) {
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "asn=64511"}`)
wantHeuristic(
t,
s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny",
`{"tag": "asn=64511"}`,
)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
@@ -41,8 +49,16 @@ func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
// Each is refused for its tag: none is counted to break the limit of one.
for range 3 {
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "torexit"}`)
wantHeuristic(
t,
s.get(
fromDE,
http.StatusForbidden,
requestlog.ActionClientDenied,
),
"tag_deny",
`{"tag": "torexit"}`,
)
}
}
@@ -58,18 +74,29 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
})
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
wantHeuristic(
t,
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny",
`{"tag": "dnsbl=`+dnsblZone+`"}`,
)
}
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
rulesDir: writeRules(
t,
"noted path log ^/\n",
), tagDeny: "exploitsignature",
})
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
line := s.get(
client,
http.StatusForbidden,
requestlog.ActionClientDenied,
)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
wantTags(t, line, "exploitsignature")
}
@@ -82,12 +109,20 @@ func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
tagLists: "vpn=" + vpnList + ",tor=" + torList,
tagLimitPercent: "vpn:50,tor:25",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
loadLists(
t,
server,
map[string][]string{vpnList: {fromDE}, torList: {fromDE}},
)
// A quarter of 4 requests a minute: the second breaks the limit.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(
fromDE,
http.StatusForbidden,
requestlog.ActionClientIsRateLimited,
)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"25 from "+tagLimitPercent)
@@ -106,7 +141,11 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
// The upload's 100 bytes are over half of 199, 99.
line := s.uploadFrom(fromDE)
if line.LimitHit != minuteBytes {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
t.Errorf(
"log line has limit_hit %q, want %s",
line.LimitHit,
minuteBytes,
)
}
wantPercent(t, "bytes_limit_percent",
@@ -116,7 +155,11 @@ func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
// fromKP, which the list does not hold, has the whole limit.
line = s.uploadFrom(fromKP)
if line.LimitHit != "" {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
t.Errorf(
"log line for %s has limit_hit %q, want none",
fromKP,
line.LimitHit,
)
}
wantPercent(t, "bytes_limit_percent",
+21 -5
View File
@@ -68,8 +68,13 @@ func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
})
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
"asn=64511", "country=KP", "vpn")
wantTags(
t,
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
"asn=64511",
"country=KP",
"vpn",
)
}
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
@@ -80,7 +85,11 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
})
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
line := s.get(
fromKP,
http.StatusForbidden,
requestlog.ActionClientDenied,
)
if line.Heuristic != "deny_nets" {
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
}
@@ -91,9 +100,16 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
line = s.get(
fromDE,
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
if line.Heuristic != "active_ban" {
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
t.Errorf(
"log line has heuristic %q, want active_ban",
line.Heuristic,
)
}
wantTags(t, line, "vpn")
+5 -1
View File
@@ -21,7 +21,11 @@ type trapPaths struct{}
func (trapPaths) name() string { return "trap_paths" }
func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
func (trapPaths) check(
_ context.Context,
rq *request,
now time.Time,
) *verdict {
path := rules.Path(rq.in)
if !slices.Contains(rq.h.config.TrapPaths, path) &&
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
+57 -13
View File
@@ -39,15 +39,27 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
}
// A client in SWW_ALLOW_NETS is not checked.
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
s.request(
allowed,
"/xmlrpc.php",
http.StatusOK,
requestlog.ActionForward,
)
// The query is not part of the path.
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
requestlog.ActionTemporarilyBanned)
line := s.request(
client,
"/wp-login.php?redirect_to=x",
http.StatusTooManyRequests,
requestlog.ActionTemporarilyBanned,
)
wantRuleIDs(t, line)
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires)
t.Errorf(
"log line has ban_expires %q, want seven days on",
line.BanExpires,
)
}
netblock := netip.MustParsePrefix(client + "/32")
@@ -77,36 +89,63 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
}
// The next request is refused under the ban, and makes it permanent.
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
line = s.get(
client,
http.StatusTooManyRequests,
requestlog.ActionPermanentlyBanned,
)
if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
t.Errorf(
"log line has ban_expires %q, want permanent",
line.BanExpires,
)
}
}
func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
s, _, _ := startWithClock(
t,
"",
map[string]string{trapPaths: trapPathList},
)
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
line := s.request(
client,
"/wp-login.php?redirect_to=x",
http.StatusForbidden,
requestlog.ActionTemporarilyBanned,
)
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
}
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
s, _, server := startWithClock(
t,
"",
map[string]string{trapPaths: trapPathList},
)
// %2577 decodes once to %77, not to w.
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
s.request(
otherClient,
"/%2577p-login.php",
http.StatusOK,
requestlog.ActionForward,
)
s.request(client, "/%77p-login.php", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
// The ban's notes give the path as the client sent it.
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
if len(got) != 1 || got[0].Notes.TrapPath != "/%77p-login.php" {
t.Fatalf("bans %+v, want one whose notes give the trap path /%%77p-login.php", got)
t.Fatalf(
"bans %+v, want one whose notes give the trap path /%%77p-login.php",
got,
)
}
}
@@ -130,7 +169,12 @@ func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
mode: observe,
})
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
line := s.request(
client,
"/xmlrpc.php",
http.StatusOK,
requestlog.ActionForward,
)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
// No ban was made.
+52 -17
View File
@@ -108,9 +108,12 @@ func New(params Params) *Server {
return &Server{
Server: &http.Server{
Addr: params.Config.ListenAddr,
Handler: h,
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
Addr: params.Config.ListenAddr,
Handler: h,
ErrorLog: slog.NewLogLogger(
params.ProcessLog.Handler(),
slog.LevelWarn,
),
ReadTimeout: wafTimeout,
WriteTimeout: wafTimeout,
IdleTimeout: wafIdleTimeout,
@@ -153,7 +156,8 @@ func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
_, decided := rq.crowdSecDecisionInForce(now)
return decided || rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
return decided ||
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
rq.overAWholeRateLimit(now)
}
@@ -169,8 +173,13 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
// One that cannot be read leaves the log line's peer_ip invalid.
peer, _ := netip.ParseAddr(r.Header.Get(PeerHeader))
rq := h.startRequest(w, asTheVisitorSentIt(r, body), client.Unmap(), peer.Unmap(),
true)
rq := h.startRequest(
w,
asTheVisitorSentIt(r, body),
client.Unmap(),
peer.Unmap(),
true,
)
// The last deferred runs first, so that what is held has the finished log
// line. Go's server sends the answer only once this function has
// returned, so the report cannot come before it is held.
@@ -208,7 +217,11 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
// own address, so this answers as a path with no endpoint does.
rq.line.Action = requestlog.ActionOwnEndpoint
rq.out.Header().Set(DispositionHeader, DispositionDeny)
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
http.Error(
rq.out,
http.StatusText(http.StatusNotFound),
http.StatusNotFound,
)
}
// readQuestion returns the visitor's address and the body a question carries,
@@ -238,7 +251,9 @@ func (h *handler) readQuestion(
return netip.Addr{}, nil, false
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, QuestionBodyMaxBytes))
body, err := io.ReadAll(
http.MaxBytesReader(w, r.Body, QuestionBodyMaxBytes),
)
if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge {
h.refuseQuestion(w, "the body is over 64 KiB")
@@ -247,7 +262,11 @@ func (h *handler) readQuestion(
}
if err != nil {
h.processLog.Warn("a question's body stopped arriving", "error", err.Error())
h.processLog.Warn(
"a question's body stopped arriving",
"error",
err.Error(),
)
// This panic has Go's server close the connection without an answer,
// and log nothing.
panic(http.ErrAbortHandler)
@@ -255,7 +274,10 @@ func (h *handler) readQuestion(
held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
if held {
h.refuseQuestion(w, requestIDHeader+" is the id of a request waf holds")
h.refuseQuestion(
w,
requestIDHeader+" is the id of a request waf holds",
)
return netip.Addr{}, nil, false
}
@@ -389,7 +411,9 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
var report Report
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes))
body, err := io.ReadAll(
http.MaxBytesReader(w, r.Body, reportBodyMaxBytes),
)
if err != nil {
return report, fmt.Errorf("read the body: %w", err)
}
@@ -445,11 +469,18 @@ func (report *Report) checkRanges() error {
// request to its client's history, the anomaly counters and the metrics, with
// the status and bytes the report gives. A denied request's counting was
// finished at its decision. ctx is the report's own context.
func (h *handler) finishWith(ctx context.Context, held *decision, report Report) {
func (h *handler) finishWith(
ctx context.Context,
held *decision,
report Report,
) {
rq := &request{
decision: *held,
h: h,
out: &responseWriter{status: report.Status, bytes: report.ResponseBytes},
decision: *held,
h: h,
out: &responseWriter{
status: report.Status,
bytes: report.ResponseBytes,
},
requestBytes: report.RequestBytes,
}
@@ -466,9 +497,13 @@ func (h *handler) finishWith(ctx context.Context, held *decision, report Report)
rq.countAnomalies()
rq.addToHistory()
rq.h.metrics.RequestReported(&rq.line,
rq.h.metrics.RequestReported(
&rq.line,
time.Duration(report.DurationTotalMS*float64(time.Millisecond)),
time.Duration(report.DurationUpstreamTotalMS*float64(time.Millisecond)))
time.Duration(
report.DurationUpstreamTotalMS*float64(time.Millisecond),
),
)
}
rq.writeLine()
+317 -79
View File
@@ -185,7 +185,13 @@ func (s *WAF) history() ratelimit.History {
func (s *WAF) metrics() string {
s.t.Helper()
req := newRequest(s.t, http.MethodGet, s.addr, waf.MetricsPath, http.NoBody)
req := newRequest(
s.t,
http.MethodGet,
s.addr,
waf.MetricsPath,
http.NoBody,
)
req.Header.Set("Authorization", bearer)
got := do(s.t, req)
@@ -195,13 +201,22 @@ func (s *WAF) metrics() string {
}
// wantDisposition checks an answer's status and disposition.
func wantDisposition(t *testing.T, got answer, status int, disposition string) {
func wantDisposition(
t *testing.T,
got answer,
status int,
disposition string,
) {
t.Helper()
wantStatus(t, got, status)
if answered := got.header.Get(waf.DispositionHeader); answered !=
disposition {
t.Errorf("answered with disposition %q, want %q", answered, disposition)
t.Errorf(
"answered with disposition %q, want %q",
answered,
disposition,
)
}
}
@@ -228,8 +243,13 @@ func wantProcessMessages(t *testing.T, s *WAF, msg string, count int) {
}
if found != count {
t.Errorf("%d process warnings %q, want %d; output:\n%s", found, msg, count,
s.out.text())
t.Errorf(
"%d process warnings %q, want %d; output:\n%s",
found,
msg,
count,
s.out.text(),
)
}
}
@@ -259,15 +279,21 @@ func TestQuestionAllowedIsAnsweredAndLogged(t *testing.T) {
wantBody(t, got, "")
if id := got.header.Get(requestIDHeader); id != "" {
t.Errorf("answered with the id %q, want none: rproxy makes the id", id)
t.Errorf(
"answered with the id %q, want none: rproxy makes the id",
id,
)
}
line := s.out.requestLine(t)
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.ClientIP != client || line.PeerIP != rproxyPeer || line.Host != siteA ||
line.Path != "/page" || line.Query != "q=1&token=visible" ||
line.RequestID != "the-id" || line.Scheme != "https" ||
if line.ClientIP != client || line.PeerIP != rproxyPeer ||
line.Host != siteA ||
line.Path != "/page" ||
line.Query != "q=1&token=visible" ||
line.RequestID != "the-id" ||
line.Scheme != "https" ||
line.ContentLength != 1234 ||
line.WAFLine != requestlog.WAFLineDecision {
t.Errorf("log line is %+v", line.Line)
@@ -297,7 +323,11 @@ func TestIPv4VisitorInIPv6FormIsTheIPv4Client(t *testing.T) {
line := s.out.requestLine(t)
if line.ClientIP != client || line.PeerIP != rproxyPeer {
t.Errorf("log line has client_ip %q and peer_ip %q", line.ClientIP, line.PeerIP)
t.Errorf(
"log line has client_ip %q and peer_ip %q",
line.ClientIP,
line.PeerIP,
)
}
}
@@ -344,7 +374,10 @@ func TestMalformedQuestionGets400AndAWarningAndNoLine(t *testing.T) {
metrics := s.metrics()
if strings.Contains(metrics, `status_class="4xx"`) {
t.Errorf("a refused question is counted in the metrics:\n%s", metrics)
t.Errorf(
"a refused question is counted in the metrics:\n%s",
metrics,
)
}
})
}
@@ -365,7 +398,9 @@ func TestQuestionWithABodyOver64KiBGets400(t *testing.T) {
s := startWAF(t, "", nil)
over := strings.NewReader(strings.Repeat("x", waf.QuestionBodyMaxBytes+1))
over := strings.NewReader(
strings.Repeat("x", waf.QuestionBodyMaxBytes+1),
)
got := do(t, s.questionWith(http.MethodPost, siteA, client, "/", over))
wantDisposition(t, got, http.StatusBadRequest, "")
wantProcessMessages(t, s, "a question is refused", 1)
@@ -390,7 +425,12 @@ func TestQuestionWithAHeldIDIsRefused(t *testing.T) {
// A denied request's id is held as well.
denied := s.question(siteA, otherClient, waf.HealthPath)
wantDisposition(t, do(t, denied), http.StatusNotFound, waf.DispositionDeny)
wantDisposition(
t,
do(t, denied),
http.StatusNotFound,
waf.DispositionDeny,
)
again = s.question(siteA, client, "/")
again.Header.Set(requestIDHeader, denied.Header.Get(requestIDHeader))
@@ -463,7 +503,10 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
line := s.out.requestLine(t)
if line.ResponseBytes != 0 ||
line.WAFLine != requestlog.WAFLineDecision {
t.Errorf("the report reached the question being handled: %+v", line.Line)
t.Errorf(
"the report reached the question being handled: %+v",
line.Line,
)
}
if held := s.server.Ledger.Snapshot(); len(held) != 0 {
@@ -495,10 +538,18 @@ func TestPeerAbsentOrUnreadableIsAnsweredAndLoggedInvalid(t *testing.T) {
req.Header.Set(waf.PeerHeader, peer)
}
wantDisposition(t, do(t, req), http.StatusOK, waf.DispositionAllow)
wantDisposition(
t,
do(t, req),
http.StatusOK,
waf.DispositionAllow,
)
if line := s.out.requestLine(t); line.PeerIP != "invalid IP" {
t.Errorf("log line has peer_ip %q, want invalid IP", line.PeerIP)
t.Errorf(
"log line has peer_ip %q, want invalid IP",
line.PeerIP,
)
}
})
}
@@ -512,15 +563,26 @@ func TestContentLengthUnreadableOrNegativeCountsAsAbsent(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{
rulesDir: writeRules(t, "any-length header:Content-Length block .\n"),
rulesDir: writeRules(
t,
"any-length header:Content-Length block .\n",
),
})
req := s.question(siteA, client, "/")
req.Header.Set(waf.ContentLengthHeader, announced)
wantDisposition(t, do(t, req), http.StatusOK, waf.DispositionAllow)
wantDisposition(
t,
do(t, req),
http.StatusOK,
waf.DispositionAllow,
)
if line := s.out.requestLine(t); line.ContentLength != 0 {
t.Errorf("log line has content_length %d, want none", line.ContentLength)
t.Errorf(
"log line has content_length %d, want none",
line.ContentLength,
)
}
})
}
@@ -596,8 +658,12 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startWAFWith(t, "", map[string]string{metricsToken: token},
func(server *waf.Server) { server.ReadTimeout = readTimeout })
s := startWAFWith(
t,
"",
map[string]string{metricsToken: token},
func(server *waf.Server) { server.ReadTimeout = readTimeout },
)
conn := dial(t, s.addr)
sendQuestionWithPartOfItsBody(t, conn)
@@ -610,17 +676,30 @@ func TestQuestionWhoseBodyStopsArrivingGetsNoAnswer(t *testing.T) {
sent, err := io.ReadAll(conn)
if err != nil || len(sent) != 0 {
t.Errorf("rproxy read %q (%v), want the connection closed unanswered",
sent, err)
t.Errorf(
"rproxy read %q (%v), want the connection closed unanswered",
sent,
err,
)
}
wantProcessMessages(t, s, "a question's body stopped arriving", 1)
wantProcessMessages(
t,
s,
"a question's body stopped arriving",
1,
)
wantNoRequestLines(t, s)
// The id was not taken.
again := s.question(siteA, client, "/")
again.Header.Set(requestIDHeader, "an-id")
wantDisposition(t, do(t, again), http.StatusOK, waf.DispositionAllow)
wantDisposition(
t,
do(t, again),
http.StatusOK,
waf.DispositionAllow,
)
})
}
}
@@ -639,7 +718,12 @@ func TestQuestionDeniedGetsTheBanResponse(t *testing.T) {
got := s.ask(client, "/")
wantDisposition(t, got, status, waf.DispositionDeny)
wantBody(t, got, http.StatusText(status)+"\n")
wantLine(t, s.out.requestLine(t), status, requestlog.ActionClientDenied)
wantLine(
t,
s.out.requestLine(t),
status,
requestlog.ActionClientDenied,
)
})
}
}
@@ -656,11 +740,18 @@ func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) {
wantBody(t, got, "")
// The visitor is sent nothing.
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionClientDenied)
wantMetric(t, s.metrics(), `sww_requests_total{action="client_denied",instance="`+
alertInstance+`",status_class="none"}`, 1)
wantMetric(
t,
s.metrics(),
`sww_requests_total{action="client_denied",instance="`+
alertInstance+`",status_class="none"}`,
1,
)
}
func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(
t *testing.T,
) {
t.Parallel()
s := startWAF(t, "", map[string]string{
@@ -679,7 +770,9 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
requestlog.ActionTemporarilyBanned)
}
func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(
t *testing.T,
) {
t.Parallel()
s := startWAF(t, "", map[string]string{
@@ -693,7 +786,12 @@ func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
wantDisposition(t, got, http.StatusNotFound, waf.DispositionDeny)
}
wantLine(t, s.out.requestLine(t), http.StatusNotFound, requestlog.ActionOwnEndpoint)
wantLine(
t,
s.out.requestLine(t),
http.StatusNotFound,
requestlog.ActionOwnEndpoint,
)
got := s.ask(otherClient, waf.MetricsPath)
wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny)
@@ -712,7 +810,9 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
wantWouldAction(t, line, requestlog.ActionClientDenied)
}
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(
t *testing.T,
) {
t.Parallel()
s := startWAF(t, "", map[string]string{
@@ -725,9 +825,14 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
}
lines := s.out.requestLines(t, 2)
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf("log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals)
if lines[0].LimitHit != "" ||
lines[1].LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf(
"log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit,
lines[1].LimitHit,
requestlog.LimitHitMinuteRefusals,
)
}
}
@@ -744,9 +849,14 @@ func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
}
lines := s.out.requestLines(t, 2)
if lines[0].LimitHit != "" || lines[1].LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf("log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit, lines[1].LimitHit, requestlog.LimitHitMinuteRefusals)
if lines[0].LimitHit != "" ||
lines[1].LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf(
"log lines have limit_hit %q and %q, want none and %q",
lines[0].LimitHit,
lines[1].LimitHit,
requestlog.LimitHitMinuteRefusals,
)
}
}
@@ -771,10 +881,19 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
got := s.ask(fromDE, "/")
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
asn, country := got.header.Get(
"X-Client-Asn",
), got.header.Get(
"X-Client-Country",
)
if asn != tc.wantASN || country != tc.wantCountry {
t.Errorf("answered with the AS number %q and the country %q, want %q and %q",
asn, country, tc.wantASN, tc.wantCountry)
t.Errorf(
"answered with the AS number %q and the country %q, want %q and %q",
asn,
country,
tc.wantASN,
tc.wantCountry,
)
}
})
}
@@ -832,14 +951,21 @@ func TestReportCountsAtThePercentageItsQuestionWasGiven(t *testing.T) {
})
s.passedOn(fromKP)
wantDisposition(t, s.ask(fromKP, "/"), http.StatusOK, waf.DispositionAllow)
wantDisposition(
t,
s.ask(fromKP, "/"),
http.StatusOK,
waf.DispositionAllow,
)
s.passedOn(fromDE)
wantDisposition(t, s.ask(fromDE, "/"), http.StatusForbidden,
waf.DispositionDeny)
}
func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing.T) {
func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(
t *testing.T,
) {
t.Parallel()
s := startWAF(t, "", map[string]string{anomalyClientPerMinute: "1000"})
@@ -847,11 +973,17 @@ func TestReportAddsTheRequestToItsClientsHistoryAndTheAnomalyCounters(t *testing
id := s.allowed(client)
if requests := s.history().Requests; requests != 0 {
t.Errorf("history holds %d requests before the report, want none", requests)
t.Errorf(
"history holds %d requests before the report, want none",
requests,
)
}
if counters := s.server.Anomalies.Snapshot(); len(counters) != 0 {
t.Errorf("anomaly counters %v before the report, want none", counters)
t.Errorf(
"anomaly counters %v before the report, want none",
counters,
)
}
got := s.report(waf.Report{
@@ -882,7 +1014,12 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
s.allowed(client)
denied := s.question(siteA, client, "/")
wantDisposition(t, do(t, denied), http.StatusForbidden, waf.DispositionDeny)
wantDisposition(
t,
do(t, denied),
http.StatusForbidden,
waf.DispositionDeny,
)
counters := s.server.Anomalies.Snapshot()
if len(counters) != 1 || counters[0].Minute.Current != 1 {
@@ -900,13 +1037,19 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
wantMetric(t, s.metrics(), series, 1)
got := s.report(waf.Report{
RequestID: denied.Header.Get(requestIDHeader), Status: http.StatusForbidden,
RequestID: denied.Header.Get(
requestIDHeader,
), Status: http.StatusForbidden,
ResponseBytes: 1000, DurationTotalMS: 2,
})
wantStatus(t, got, http.StatusNoContent)
if after := s.history(); after != history {
t.Errorf("the report changed the history to %+v, from %+v", after, history)
t.Errorf(
"the report changed the history to %+v, from %+v",
after,
history,
)
}
counters = s.server.Anomalies.Snapshot()
@@ -929,8 +1072,12 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
metrics := s.metrics()
wantNoSeries(t, metrics, "sww_requests_total"+series)
wantMetric(t, metrics, `sww_requests_in_flight{instance="`+alertInstance+`"}`,
1) // the scrape itself
wantMetric(
t,
metrics,
`sww_requests_in_flight{instance="`+alertInstance+`"}`,
1,
) // the scrape itself
got := s.report(waf.Report{
RequestID: id, Status: http.StatusBadGateway,
@@ -955,8 +1102,12 @@ func TestMetricsCountAnAllowedRequestFromItsReport(t *testing.T) {
DurationTotalMS: 5,
})
wantStatus(t, got, http.StatusNoContent)
wantMetric(t, unscraped.metrics(),
`sww_request_duration_seconds_sum{instance="`+alertInstance+`"}`, 0.005)
wantMetric(
t,
unscraped.metrics(),
`sww_request_duration_seconds_sum{instance="`+alertInstance+`"}`,
0.005,
)
}
func TestReportForARequestNotHeldIsIgnored(t *testing.T) {
@@ -988,7 +1139,11 @@ func TestReportForARequestNotHeldIsIgnored(t *testing.T) {
}
if counted := s.history().ResponseBytes; counted != answerBytes {
t.Errorf("history holds %d response bytes, want %d", counted, answerBytes)
t.Errorf(
"history holds %d response bytes, want %d",
counted,
answerBytes,
)
}
// The question's line and one for the first report, none for the second.
@@ -1061,7 +1216,11 @@ func TestReportThatIsNotOneObjectWithAllItsFieldsIsRefused(t *testing.T) {
wantStatus(t, s.reportBody(whole), http.StatusNoContent)
if counted := s.history().ResponseBytes; counted != answerBytes {
t.Errorf("history holds %d response bytes, want %d", counted, answerBytes)
t.Errorf(
"history holds %d response bytes, want %d",
counted,
answerBytes,
)
}
}
@@ -1073,21 +1232,35 @@ func TestReportOver1MiBIsRefusedAndOneOfExactly1MiBIsTaken(t *testing.T) {
id := s.allowed(client)
// The padding is a field of no meaning here, so only the size can refuse it.
padded := func(size int) string {
return encodedReport(t, id, map[string]any{"padding": strings.Repeat("x", size)})
return encodedReport(
t,
id,
map[string]any{"padding": strings.Repeat("x", size)},
)
}
paddingBytes := reportBodyLimit - len(padded(0))
wantStatus(t, s.reportBody(padded(paddingBytes+1)), http.StatusBadRequest)
wantStatus(
t,
s.reportBody(padded(paddingBytes+1)),
http.StatusBadRequest,
)
wantProcessMessages(t, s, "a report is refused", 1)
if requests := s.history().Requests; requests != 0 {
t.Errorf("history holds %d requests, want the refused report not counted", requests)
t.Errorf(
"history holds %d requests, want the refused report not counted",
requests,
)
}
wantStatus(t, s.reportBody(padded(paddingBytes)), http.StatusNoContent)
if requests := s.history().Requests; requests != 1 {
t.Errorf("history holds %d requests, want the report of 1 MiB counted", requests)
t.Errorf(
"history holds %d requests, want the report of 1 MiB counted",
requests,
)
}
}
@@ -1192,7 +1365,10 @@ func wantReportsFields(t *testing.T, line logLine) {
line.RequestBytes != bodyBytes || line.ResponseBytes != answerBytes ||
line.DurationTotalMS != 12.5 || line.DurationUpstreamTotalMS == nil ||
*line.DurationUpstreamTotalMS != 10.25 {
t.Errorf("the report's line lacks the report's fields: %+v", line.Line)
t.Errorf(
"the report's line lacks the report's fields: %+v",
line.Line,
)
}
}
@@ -1220,7 +1396,11 @@ func wantSameDecision(t *testing.T, reported, decided logLine) {
// wantDecisionsFields checks what waf answered and noted on the decision's
// line, of a request with these fields.
func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) {
func wantDecisionsFields(
t *testing.T,
line logLine,
id, query, accept string,
) {
t.Helper()
if line.WAFLine != requestlog.WAFLineDecision ||
@@ -1234,7 +1414,9 @@ func wantDecisionsFields(t *testing.T, line logLine, id, query, accept string) {
}
}
func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(t *testing.T) {
func TestReportWritesTheLastLineWithTheDecisionsAndTheReportsFields(
t *testing.T,
) {
t.Parallel()
s := startWAF(t, "", map[string]string{
@@ -1353,29 +1535,46 @@ func TestOldestAnsweredRequestIsDroppedPastTheBound(t *testing.T) {
t.Fatalf("question: %v", err)
}
wantDisposition(t, readAnswer(res), http.StatusOK, waf.DispositionAllow)
wantDisposition(
t,
readAnswer(res),
http.StatusOK,
waf.DispositionAllow,
)
}
// The table is full again with next and those, without oldest.
got := s.report(waf.Report{RequestID: oldest, ResponseBytes: answerBytes})
got := s.report(
waf.Report{RequestID: oldest, ResponseBytes: answerBytes},
)
wantStatus(t, got, http.StatusNoContent)
if counted := s.history().Requests; counted != 0 {
t.Errorf("history holds %d requests, want the dropped one not counted", counted)
t.Errorf(
"history holds %d requests, want the dropped one not counted",
counted,
)
}
got = s.report(waf.Report{RequestID: next, ResponseBytes: answerBytes})
wantStatus(t, got, http.StatusNoContent)
if counted := s.history().Requests; counted != 1 {
t.Errorf("history holds %d requests, want the one still held", counted)
t.Errorf(
"history holds %d requests, want the one still held",
counted,
)
}
}
func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{rulesDir: writeRules(t, testRules)})
s := startWAF(
t,
"",
map[string]string{rulesDir: writeRules(t, testRules)},
)
got := do(t, s.question(siteA, client, "/.env"))
wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny)
@@ -1385,17 +1584,29 @@ func TestBanMadeThroughOneHostRefusesTheClientOnAnother(t *testing.T) {
lines := s.out.requestLines(t, 2)
if lines[0].Host != siteA || lines[1].Host != siteB {
t.Errorf("log lines have the hosts %q and %q", lines[0].Host, lines[1].Host)
t.Errorf(
"log lines have the hosts %q and %q",
lines[0].Host,
lines[1].Host,
)
}
held := s.server.Ledger.Snapshot()
if len(held) != 1 || held[0].Notes.Request.Host != siteA {
t.Errorf("bans held are %+v, want one whose notes name %s", held, siteA)
t.Errorf(
"bans held are %+v, want one whose notes name %s",
held,
siteA,
)
}
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) == 0 || waiting[0].Host != siteA {
t.Errorf("alerts waiting are %+v, want the ban's, naming %s", waiting, siteA)
t.Errorf(
"alerts waiting are %+v, want the ban's, naming %s",
waiting,
siteA,
)
}
}
@@ -1441,7 +1652,12 @@ func TestOwnEndpointsAnswerOnlyWithoutTheClientHeader(t *testing.T) {
req.Header.Set("Authorization", tc.authorization)
if strings.HasPrefix(tc.path, "/_sww/") {
wantDisposition(t, do(t, req), http.StatusNotFound, waf.DispositionDeny)
wantDisposition(
t,
do(t, req),
http.StatusNotFound,
waf.DispositionDeny,
)
} else {
wantDisposition(t, do(t, req), http.StatusOK, waf.DispositionAllow)
}
@@ -1494,7 +1710,12 @@ func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
wantStatus(t, do(t, req), http.StatusBadRequest)
s.allowed(client)
wantLine(t, s.out.requestLine(t), http.StatusOK, requestlog.ActionForward)
wantLine(
t,
s.out.requestLine(t),
http.StatusOK,
requestlog.ActionForward,
)
for _, line := range s.out.lines(t) {
if line["type"] == "process" {
@@ -1508,10 +1729,15 @@ func TestServerHasTheTimeLimitsSPECGives(t *testing.T) {
s := startWAF(t, "", nil)
if s.server.ReadTimeout != time.Minute || s.server.WriteTimeout != time.Minute ||
if s.server.ReadTimeout != time.Minute ||
s.server.WriteTimeout != time.Minute ||
s.server.IdleTimeout != 2*time.Minute {
t.Errorf("the read, write and idle limits are %v, %v and %v, want 1m, 1m and 2m",
s.server.ReadTimeout, s.server.WriteTimeout, s.server.IdleTimeout)
t.Errorf(
"the read, write and idle limits are %v, %v and %v, want 1m, 1m and 2m",
s.server.ReadTimeout,
s.server.WriteTimeout,
s.server.IdleTimeout,
)
}
}
@@ -1584,10 +1810,14 @@ func TestCoreRuleSetAlertNamesTheSite(t *testing.T) {
wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny)
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventCoreRuleSetMatch ||
if len(waiting) != 1 ||
waiting[0].Event != alerts.EventCoreRuleSetMatch ||
waiting[0].Host != siteA {
t.Errorf("alerts waiting are %+v, want a core_rule_set_match naming %s",
waiting, siteA)
t.Errorf(
"alerts waiting are %+v, want a core_rule_set_match naming %s",
waiting,
siteA,
)
}
}
@@ -1603,7 +1833,11 @@ func TestReputationHitAlertNamesTheSite(t *testing.T) {
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
waiting[0].Host != siteA {
t.Errorf("alerts waiting are %+v, want a reputation_hit naming %s", waiting, siteA)
t.Errorf(
"alerts waiting are %+v, want a reputation_hit naming %s",
waiting,
siteA,
)
}
}
@@ -1619,6 +1853,10 @@ func TestAnomalyAlertNamesTheSite(t *testing.T) {
waiting := s.queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventAnomaly ||
waiting[0].Host != siteA {
t.Errorf("alerts waiting are %+v, want an anomaly naming %s", waiting, siteA)
t.Errorf(
"alerts waiting are %+v, want an anomaly naming %s",
waiting,
siteA,
)
}
}