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:
1 parent
63e58e326b
commit
7f8a59de04
118 files changed
+9447
-2670
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user