Name request log actions so they say what they mean (closes #229)
check / check (push) Successful in 3m46s

Rename too_large, rate_limited, rule_blocked and denied, and split banned
into temporarily_banned and permanently_banned by whether the ban runs out.
A request that makes a ban permanent gets permanently_banned. A ban the
request itself makes gets the value for that ban, and in observe mode
would_action names the ban enforce mode would have made.

The offence kind rule_blocked follows, as a metric label and a key in
clients.json.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-09 03:43:24 +00:00
1 parent d7c62f2854
commit ca9e97998f
45 files changed
+429 -298

No files matched your search

+1 -1
View File
@@ -50,7 +50,7 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
}
return &verdict{
action: requestlog.ActionDenied,
action: requestlog.ActionClientDenied,
figures: map[string]any{
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
},
+2 -2
View File
@@ -121,7 +121,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
http.StatusOK), want)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
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)
}
@@ -276,7 +276,7 @@ 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.ActionRateLimited)
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
// Asked about by its address in IPv6 form too.
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
+9 -7
View File
@@ -31,14 +31,14 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
start := clk.Now()
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
netblock := netip.MustParsePrefix("203.0.113.0/24")
ban := server.Ledger.Bans(netblock)[0]
// A request refused under the ban raises no other alert.
clk.advance(time.Minute)
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
Netblock: netblock, Cause: bans.CauseLimit,
@@ -62,22 +62,24 @@ 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.ActionBanned)
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
attackBan := server.Ledger.Bans(netblock)[0]
clk.advance(time.Minute)
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
permanentBan := server.Ledger.Bans(netblock)[0]
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
// Another client's probe after its first ban has run out without a
// request makes a permanent ban at once.
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
s.request(otherClient, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
clk.advance(7 * 24 * time.Hour)
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
s.request(otherClient, "/.env", http.StatusForbidden,
requestlog.ActionPermanentlyBanned)
otherBans := server.Ledger.Bans(other)
+1 -1
View File
@@ -187,7 +187,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
{allowed, "/", http.StatusOK, requestlog.ActionForward},
{exempt, "/", http.StatusOK, requestlog.ActionForward},
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
{denied, "/", http.StatusForbidden, requestlog.ActionDenied},
{denied, "/", http.StatusForbidden, requestlog.ActionClientDenied},
} {
for range 3 {
s.request(sent.from, sent.path, sent.status, sent.action)
+22 -16
View File
@@ -84,7 +84,7 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
if line.DurationWAF != nil {
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
@@ -157,7 +157,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
if tc.refused {
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
return
}
@@ -219,9 +219,10 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
line.DurationWAF != nil {
t.Errorf("would_action %q and duration_waf %v, want %q and none",
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
line.WouldAction, line.DurationWAF, requestlog.ActionRequestHeaderTooLarge)
}
}
@@ -240,7 +241,7 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
@@ -249,8 +250,8 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
for _, tc := range []struct {
name, path, action string
}{
{"a trap path", "/wp-login.php", requestlog.ActionBanned},
{"a block rule", "/blocked", requestlog.ActionRuleBlocked},
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -280,7 +281,7 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
if line.LimitPercent != nil {
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
@@ -304,7 +305,8 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
}
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
@@ -412,33 +414,37 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
}{
{
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
},
{
name: "a ban",
prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
},
{
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
prepare: countClient, status: forbidden,
action: requestlog.ActionClientIsRateLimited,
},
{
name: "the rate limits, with a biased threshold",
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
prepare: countClient, status: forbidden,
action: requestlog.ActionClientIsRateLimited,
},
{
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
},
{
name: "the CrowdSec decision list",
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
prepare: decideOnClient, status: forbidden,
action: requestlog.ActionTemporarilyBanned,
},
{
name: "the header limits", cookie: cookies(cookieLimit + 1),
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
prepare: leaveAsItIs, status: tooLarge,
action: requestlog.ActionRequestHeaderTooLarge,
},
} {
t.Run(tc.name, func(t *testing.T) {
+29 -13
View File
@@ -50,13 +50,23 @@ func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
}
return &verdict{
action: requestlog.ActionBanned,
action: banAction(ban.Permanent()),
figures: map[string]any{
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
},
}
}
// banAction is the action of a request refused under a ban or for making one:
// permanent says the ban never runs out.
func banAction(permanent bool) string {
if permanent {
return requestlog.ActionPermanentlyBanned
}
return requestlog.ActionTemporarilyBanned
}
// limitFigures are the figures of a heuristic that broke hit, a limit: its
// window, the count and the limit.
func limitFigures(hit ratelimit.Hit) map[string]any {
@@ -90,8 +100,8 @@ func (rq *request) countedBytes() int64 {
// rate limit or a byte limit; the error burst is not lowered. The ban sets
// 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) {
// if that alert would be sent. It returns the action for that ban.
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) string {
switch hit.Kind {
case ratelimit.KindBytes:
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
@@ -106,7 +116,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
return
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseLimit))
}
notes := bans.Notes{
@@ -135,7 +145,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
rq.alertBan(ban)
}
return
return banAction(ban.Permanent())
}
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
@@ -145,17 +155,19 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
if made {
rq.alertBan(ban)
}
return banAction(ban.Permanent())
}
// banForAttack bans the client's netblock at now for a clear sign of
// attack, which notes name: the ban rule that matched, or the trap path
// asked for. It fills in the rest of the notes. In observe mode it makes
// no ban, and raises the alert for the ban it would have made, if that
// alert would be sent.
func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
// 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
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
}
notes.ASN = rq.line.ASN
@@ -171,7 +183,7 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
rq.alertBan(ban)
}
return
return banAction(ban.Permanent())
}
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
@@ -180,16 +192,18 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
if made {
rq.alertBan(ban)
}
return banAction(ban.Permanent())
}
// banForCrowdSec bans the client's netblock at now until decision,
// 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.
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
// would be sent. It returns the action for that ban.
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
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
}
notes := bans.Notes{
@@ -208,7 +222,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
rq.alertBan(ban)
}
return
return banAction(ban.Permanent())
}
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
@@ -218,6 +232,8 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
if made {
rq.alertBan(ban)
}
return banAction(ban.Permanent())
}
// wouldAlertBan reports whether the alert for a ban on netblock for cause
+26 -24
View File
@@ -40,7 +40,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
// client for an hour, the default.
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
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, "+
@@ -51,7 +51,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
// Every request while the ban lasts is refused.
clk.advance(time.Hour - time.Second)
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
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,
@@ -78,7 +78,9 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
breakLimit := func() string {
s.get(client, http.StatusOK, requestlog.ActionForward)
return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
return line.BanExpires
}
wantExpires := func(got string, length time.Duration) {
t.Helper()
@@ -111,7 +113,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
}
clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
}
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
@@ -123,10 +125,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
// client for an hour.
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
for range 3 {
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
// Later the same day the client has its whole allowance again: the
@@ -135,7 +137,7 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
@@ -190,10 +192,10 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
s, _, _ := startWithClock(t, "", env)
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
for _, sent := range tc.refused {
s.get(sent, http.StatusForbidden, requestlog.ActionBanned)
s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
for _, sent := range tc.let {
@@ -216,9 +218,9 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
// fromDE's ban covers otherClient, which is refused unasked about.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.Country != "" {
t.Errorf("log line has country %q, want none", line.Country)
}
@@ -237,9 +239,9 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "active_ban",
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
}
@@ -275,11 +277,11 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
s, _, _ := startWithClock(t, geojsURL, env)
s.get(denied, tc.status, requestlog.ActionDenied)
s.get(fromKP, tc.status, requestlog.ActionDenied)
s.get(denied, tc.status, requestlog.ActionClientDenied)
s.get(fromKP, tc.status, requestlog.ActionClientDenied)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
s.get(fromDE, tc.status, requestlog.ActionBanned)
s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited)
s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned)
})
}
}
@@ -297,9 +299,9 @@ func TestBanNotes(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.request(fromDE, "/repo/commits?page=2",
http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
netblock := netip.MustParsePrefix(fromDE + "/32")
want := bans.Ban{
@@ -342,7 +344,7 @@ func TestBanNotes(t *testing.T) {
// The next ban counts this one among the earlier.
clk.advance(time.Hour)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
got = ledger.Bans(netblock)
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
@@ -360,12 +362,12 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
// One ban is held, so otherClient's ban drops client's.
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
// clock is the time a test sets, by which sww counts requests and
+7 -7
View File
@@ -76,7 +76,7 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
}{
{http.StatusOK, requestlog.ActionForward},
{http.StatusOK, requestlog.ActionForward},
{http.StatusForbidden, requestlog.ActionRateLimited},
{http.StatusForbidden, requestlog.ActionClientIsRateLimited},
} {
line := s.get(tc.from, sent.status, sent.action)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
@@ -192,7 +192,7 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
// The first request breaks the limit, and bans the client; the log line
// gives the 0.
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionRateLimited)
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.fields["limit_percent"] != float64(0) ||
line.fields["limit_percent_setting"] != unknownLimitPercent {
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
@@ -200,7 +200,7 @@ func TestZeroPercentIsAZeroAllowance(t *testing.T) {
unknownLimitPercent)
}
s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned)
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
func TestLowestPercentageApplies(t *testing.T) {
@@ -250,7 +250,7 @@ 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.ActionRateLimited)
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
tc.want)
})
@@ -269,7 +269,7 @@ 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.ActionRateLimited)
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}
// One in a country has the whole limit.
@@ -293,7 +293,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
serveFromDE(t, server, http.MethodGet, http.NoBody)
line := out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
"0 from "+unknownLimitPercent)
})
@@ -374,7 +374,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
// A quarter of 12 requests a minute is 3: the fourth breaks it.
"a rate limit",
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
3, http.StatusForbidden, requestlog.ActionRateLimited,
3, http.StatusForbidden, requestlog.ActionClientIsRateLimited,
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
},
{
+1 -1
View File
@@ -28,7 +28,7 @@ func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
}
return &verdict{
action: requestlog.ActionDenied,
action: requestlog.ActionClientDenied,
figures: map[string]any{"lists": listedBy},
}
}
+3 -5
View File
@@ -6,8 +6,6 @@ package discriminator
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// byteLimits counts the request's bytes, as countedBytes gives them, for the
@@ -37,7 +35,7 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
return nil
}
rq.banForLimit(now, hit, rq.out.status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
return &verdict{
action: rq.banForLimit(now, hit, rq.out.status), figures: limitFigures(hit),
}
}
+6 -6
View File
@@ -90,7 +90,7 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
line.BanExpires, tc.window, expires)
}
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
@@ -125,7 +125,7 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
}
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
@@ -147,7 +147,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
answerBytes, minuteBytes, expires)
}
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
@@ -176,7 +176,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
}
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
})
}
}
@@ -218,7 +218,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
}
}
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
})
}
}
@@ -386,7 +386,7 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
// counted, and the byte limit is not broken. Its line gives the bytes
// counted before it.
line := s.upload()
wantWouldAction(t, line, requestlog.ActionRateLimited)
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",
+4 -3
View File
@@ -92,7 +92,8 @@ func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
wafMode: detect, tagDeny: "exploitsignature",
})
line := s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionDenied)
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
}
@@ -195,7 +196,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
// A request a rule file refuses is not inspected.
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionRuleBlocked)
requestlog.ActionBlockedByBlockRule)
wantWAF(t, line, nil)
// With SWW_WAF_MODE off, no request is.
@@ -403,7 +404,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
line.LimitHit, line.Offence)
}
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
if offences := historyOf(t, server, client).Offences; offences != want {
+1 -2
View File
@@ -30,10 +30,9 @@ func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
rq.addTag(config.CrowdSecTag)
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
return &verdict{
action: requestlog.ActionBanned,
action: rq.banForCrowdSec(now, decision),
figures: map[string]any{
"scenario": decision.Scenario,
"expires": requestlog.FormatTime(decision.Expires),
+7 -7
View File
@@ -39,7 +39,7 @@ 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.ActionBanned)
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantReputation(t, line, decisionsURL)
if line.BanExpires != expires {
@@ -72,7 +72,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
// 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.ActionBanned)
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantReputation(t, line)
if line.BanExpires != expires {
@@ -105,7 +105,7 @@ func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
`"value": "`+client+`"}]`)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
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")
@@ -129,9 +129,9 @@ 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.ActionBanned)
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned)
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned)
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.
@@ -164,7 +164,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
`"value": "`+client+`"}]`)
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
wantReputation(t, line, decisionsURL)
if held := server.Ledger.Snapshot(); len(held) != 0 {
+1 -1
View File
@@ -19,7 +19,7 @@ func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, netblock := range rq.h.config.DenyNets {
if netblock.Contains(rq.client) {
return &verdict{
action: requestlog.ActionDenied,
action: requestlog.ActionClientDenied,
figures: map[string]any{figureNetblock: netblock.String()},
}
}
+9 -9
View File
@@ -639,7 +639,7 @@ func TestQuestionDeniedGetsTheBanResponse(t *testing.T) {
got := s.ask(client, "/")
wantDisposition(t, got, status, discriminator.DispositionDeny)
wantBody(t, got, http.StatusText(status)+"\n")
wantLine(t, s.out.requestLine(t), status, requestlog.ActionDenied)
wantLine(t, s.out.requestLine(t), status, requestlog.ActionClientDenied)
})
}
}
@@ -655,8 +655,8 @@ func TestBanResponseCloseIsAnAnswerOfItsOwn(t *testing.T) {
wantDisposition(t, got, http.StatusOK, discriminator.DispositionClose)
wantBody(t, got, "")
// The visitor is sent nothing.
wantLine(t, s.out.requestLine(t), 0, requestlog.ActionDenied)
wantMetric(t, s.metrics(), `sww_requests_total{action="denied",instance="`+
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)
}
@@ -671,12 +671,12 @@ func TestBlockRuleIsDeniedWith403AndABanRuleWithTheBanResponse(t *testing.T) {
got := s.ask(client, "/blocked")
wantDisposition(t, got, http.StatusForbidden, discriminator.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
requestlog.ActionRuleBlocked)
requestlog.ActionBlockedByBlockRule)
got = s.ask(client, "/.env")
wantDisposition(t, got, http.StatusTooManyRequests, discriminator.DispositionDeny)
wantLine(t, s.out.requestLines(t, 2)[1], http.StatusTooManyRequests,
requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
}
func TestQuestionUnderTheOwnPrefixIsDeniedWith404AfterTheChecks(t *testing.T) {
@@ -709,7 +709,7 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
line := s.out.requestLine(t)
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied)
wantWouldAction(t, line, requestlog.ActionClientDenied)
}
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(t *testing.T) {
@@ -894,7 +894,7 @@ func TestDeniedRequestIsFinishedAtItsDecision(t *testing.T) {
t.Errorf("history is %+v, want the refused request alone", history)
}
const series = `sww_requests_total{action="rate_limited",instance="` +
const series = `sww_requests_total{action="client_is_rate_limited",instance="` +
alertInstance + `",status_class="4xx"}`
wantMetric(t, s.metrics(), series, 1)
@@ -1298,11 +1298,11 @@ func TestEveryDeniedRequestsReportGetsItsLastLine(t *testing.T) {
}{
{
"deny", map[string]string{denyNets: client}, "/", http.StatusForbidden,
requestlog.ActionDenied,
requestlog.ActionClientDenied,
},
{
"close", map[string]string{denyNets: client, banResponse: banResponseClose}, "/",
0, requestlog.ActionDenied,
0, requestlog.ActionClientDenied,
},
{"own prefix", nil, discriminator.HealthPath, http.StatusNotFound,
requestlog.ActionAdmin},
+1 -1
View File
@@ -37,7 +37,7 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
}
return &verdict{
action: requestlog.ActionDenied,
action: requestlog.ActionClientDenied,
figures: map[string]any{"zones": listedBy},
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict
}
return &verdict{
action: requestlog.ActionTooLarge,
action: requestlog.ActionRequestHeaderTooLarge,
figures: map[string]any{
"fields": fields, "fields_limit": maxHeaderFields,
"cookies": cookies, "cookies_limit": maxCookies,
+2 -2
View File
@@ -31,9 +31,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
clk.advance(time.Second)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
clk.advance(time.Second)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
want := ratelimit.History{
FirstSeen: start,
+3 -3
View File
@@ -40,7 +40,7 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T)
// comes too. None waits for GeoJS.
for _, line := range []logLine{
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
} {
got := asnAndCountry{line.ASN, line.ASName, line.Country}
@@ -179,7 +179,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
for _, line := range []logLine{
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
} {
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
t.Errorf("log line has %+v, want %+v", got, want)
@@ -264,7 +264,7 @@ func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
want asnAndCountry
}{
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
{s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), de},
{s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), de},
{
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
asnAndCountry{asnKP, asNameKP, "KP"},
+4 -4
View File
@@ -125,15 +125,15 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
})
// SWW_BAN_RESPONSE=close sends no status at all.
s.get(denied, 0, requestlog.ActionDenied)
s.get(denied, 0, requestlog.ActionClientDenied)
// A first broken limit bans for an hour.
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionRateLimited)
s.get(client, 0, requestlog.ActionClientIsRateLimited)
metrics := s.scrape()
wantMetric(t, metrics,
`sww_requests_total{action="denied",instance="app",status_class="none"}`, 1)
`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)
@@ -147,7 +147,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
// A limit broken again right after would ban for three hours, longer
// than SWW_MAX_BAN_DURATION, so the ban is permanent.
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionRateLimited)
s.get(client, 0, requestlog.ActionClientIsRateLimited)
metrics = s.scrape()
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
+71 -7
View File
@@ -14,6 +14,70 @@ import (
// observe is the value of SWW_MODE for observe mode.
const observe = "observe"
func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
t.Parallel()
const trapPath = "/wp-login.php"
for _, tc := range []struct {
name string
cause string
// endsIn is how long after the request the ban ends: negative for a
// ban that has ended, and zero for a permanent ban.
endsIn time.Duration
path string
want string
}{
{
"temporary ban", bans.CauseAdmin, time.Hour, "/",
requestlog.ActionTemporarilyBanned,
},
{"permanent ban", bans.CauseAdmin, 0, "/", requestlog.ActionPermanentlyBanned},
{
"request that makes a ban permanent", bans.CauseAttack, time.Hour, "/",
requestlog.ActionPermanentlyBanned,
},
{
"trap path after an attack ban ended", bans.CauseAttack, -time.Hour,
trapPath, requestlog.ActionPermanentlyBanned,
},
{
"first trap path after another ban ended", bans.CauseAdmin, -time.Hour,
trapPath, requestlog.ActionTemporarilyBanned,
},
} {
for _, modeValue := range []string{"enforce", observe} {
t.Run(tc.name+" in "+modeValue, func(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
trapPaths: trapPathList, mode: modeValue,
})
ban := bans.Ban{
Netblock: netip.MustParsePrefix(client + "/32"),
Start: clk.Now().Add(-2 * time.Hour),
Cause: tc.cause,
}
if tc.endsIn != 0 {
ban.Expires = clk.Now().Add(tc.endsIn)
}
server.Ledger.Load([]bans.Ban{ban})
if modeValue == observe {
line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, tc.want)
return
}
s.request(client, tc.path, http.StatusForbidden, tc.want)
})
}
}
}
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
t.Parallel()
@@ -22,7 +86,7 @@ func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
})
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied)
wantWouldAction(t, line, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
}
@@ -69,10 +133,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
for _, sent := range []struct{ from, refusal string }{
{denied, requestlog.ActionDenied},
{banned, requestlog.ActionBanned},
{fromKP, requestlog.ActionDenied},
{fromDE, requestlog.ActionRateLimited},
{denied, requestlog.ActionClientDenied},
{banned, requestlog.ActionTemporarilyBanned},
{fromKP, requestlog.ActionClientDenied},
{fromDE, requestlog.ActionClientIsRateLimited},
} {
if !tc.observe {
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
@@ -110,7 +174,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
for range 2 {
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRateLimited)
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
line.BanExpires != "" {
@@ -123,7 +187,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
// The ban read from bans.json refuses nothing, and so counts no
// refusal in its notes, but is kept.
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
+3 -1
View File
@@ -42,7 +42,9 @@ func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict
rq.banForLimit(now, hit, rq.h.config.BanResponse)
return &verdict{action: requestlog.ActionRateLimited, figures: limitFigures(hit)}
return &verdict{
action: requestlog.ActionClientIsRateLimited, figures: limitFigures(hit),
}
}
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
+11 -8
View File
@@ -59,7 +59,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
if sent.want == http.StatusOK {
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
} else {
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute {
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
@@ -79,7 +79,7 @@ func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
}
@@ -102,7 +102,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
status int
action string
}{
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionRateLimited},
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionClientIsRateLimited},
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
} {
t.Run(tc.setting, func(t *testing.T) {
@@ -160,14 +160,17 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
// A path outside every prefix is counted: /assets is not under
// /assets/, and breaks the limit.
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
s.request(client, "/assets", http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
// prefix.
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
s.request(client, "/assets/app.js", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden,
requestlog.ActionClientDenied)
s.request(fromKP, "/assets/app.js",
http.StatusForbidden, requestlog.ActionDenied)
http.StatusForbidden, requestlog.ActionClientDenied)
}
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
@@ -211,7 +214,7 @@ 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.ActionRateLimited)
s.request(client, sent, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
})
}
}
+4 -6
View File
@@ -28,7 +28,7 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
return nil
}
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
if !rq.attack && !rq.blockedByBlockRule && !rq.wafBlocked {
return nil
}
@@ -42,13 +42,11 @@ func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdic
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
case requestlog.ActionBlockedByBlockRule, requestlog.ActionWAFBlocked:
status = http.StatusForbidden
case requestlog.ActionBanned:
case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned:
status = cfg.BanResponse
}
rq.banForLimit(now, hit, status)
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
return &verdict{action: rq.banForLimit(now, hit, status), figures: limitFigures(hit)}
}
+22 -10
View File
@@ -37,9 +37,10 @@ func (r refused) send(s *sender, from string) logLine {
switch r {
case blockRule:
return s.request(from, blockedPath, http.StatusForbidden,
requestlog.ActionRuleBlocked)
requestlog.ActionBlockedByBlockRule)
case banRule:
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
return s.request(from, probePath, http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
}
s.t.Fatalf("no request for the refusal %d", r)
@@ -71,9 +72,20 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
name string
// refusals are four, one over the threshold of three.
refusals []refused
// nextAction is the action of the client's next request: the next
// request after a ban for a ban rule makes it permanent.
nextAction string
}{
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
{"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}},
{
"block rule",
[]refused{blockRule, blockRule, blockRule, blockRule},
requestlog.ActionTemporarilyBanned,
},
{
"a ban rule last",
[]refused{blockRule, blockRule, blockRule, banRule},
requestlog.ActionPermanentlyBanned,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -101,7 +113,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
"and limit", line.LimitHit, line.Offence)
}
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, tc.nextAction)
})
}
}
@@ -169,7 +181,7 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
}
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3}
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)
}
@@ -179,7 +191,7 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
`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="rule_blocked"}`, 3)
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3)
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
}
@@ -298,7 +310,7 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
}
s.get(exempt, http.StatusForbidden, requestlog.ActionBanned)
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
@@ -341,7 +353,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
for range 2 {
line := s.request(otherClient, blockedPath, http.StatusOK,
requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
if line.LimitHit != "" {
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
@@ -353,7 +365,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
+21 -21
View File
@@ -49,7 +49,7 @@ const (
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
for _, tc := range []struct {
action string
@@ -66,7 +66,7 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
{
// Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited},
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + blocklistAction,
},
{
@@ -162,7 +162,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
s, server, _ := startWithLookups(t, tc.env)
tc.load(t, server)
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, tc.heuristic, tc.figures)
})
}
@@ -193,7 +193,7 @@ func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied)
wantWouldAction(t, line, requestlog.ActionClientDenied)
wantReputation(t, line, dropURL)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -380,7 +380,7 @@ const (
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
for _, tc := range []struct {
action string
@@ -397,7 +397,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
{
// Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited},
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction,
},
{
@@ -468,7 +468,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
// 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.ActionDenied),
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
dropURL)
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
@@ -532,7 +532,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied)
wantWouldAction(t, line, requestlog.ActionClientDenied)
wantReputation(t, line, dnsblZone)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -741,8 +741,8 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T
s.get(fromDE, http.StatusOK, forward)
s.get(fromDE, http.StatusOK, forward)
s.get(fromKP, http.StatusOK, forward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantAbuseIPDBChecks(t, server, 0)
// Once the ban has ended, fromDE's first request has it checked in the
@@ -781,7 +781,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing
s.get(addresses[0], http.StatusOK, forward)
}
s.get(addresses[0], http.StatusForbidden, requestlog.ActionRateLimited)
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
@@ -814,20 +814,20 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
want ratelimit.Offences
}{
{
"a block rule", blockedPath, http.StatusForbidden, requestlog.ActionRuleBlocked,
ratelimit.Offences{RuleBlocked: 1},
"a block rule", blockedPath, http.StatusForbidden,
requestlog.ActionBlockedByBlockRule, ratelimit.Offences{BlockedByBlockRule: 1},
},
{
"the Core Rule Set", sqlInjection, http.StatusForbidden,
requestlog.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 1},
},
{
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned,
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned,
ratelimit.Offences{Attack: 1},
},
{
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
ratelimit.Offences{Attack: 1},
"a trap path", "/xmlrpc.php", http.StatusForbidden,
requestlog.ActionTemporarilyBanned, ratelimit.Offences{Attack: 1},
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -858,7 +858,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
for _, tc := range []struct {
action string
@@ -875,7 +875,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
{
// Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited},
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction,
},
{
@@ -972,7 +972,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
// fromDE's score is not used, and once it has committed an offence it
// is not checked either.
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
clk.advance(time.Hour)
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
wantAbuseIPDBChecks(t, server, 0)
@@ -996,10 +996,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
}{
{"for a broken rate limit", func(s *sender, from string) {
s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}},
{"for a clear sign of attack", func(s *sender, from string) {
s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
s.request(from, probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}},
} {
t.Run(tc.name, func(t *testing.T) {
+16 -15
View File
@@ -54,10 +54,10 @@ type decision struct {
counted bool
limitPercent, bytesPercent percentage
// attack is true for a request that matched a ban rule or asked for a
// trap path, ruleBlocked for one a block rule refused, and wafBlocked for
// one the Core Rule Set refused, each an offence its client's history
// counts.
attack, ruleBlocked, wafBlocked bool
// trap path, blockedByBlockRule for one a block rule refused, and
// wafBlocked for one the Core Rule Set refused, each an offence its
// client's history counts.
attack, blockedByBlockRule, wafBlocked bool
// reputation is the reputation sources that list the client, for the
// notes of a ban the request makes.
reputation []bans.ReputationHit
@@ -187,9 +187,10 @@ func (rq *request) check(ctx context.Context) *refusal {
rq.line.WouldAction = action
return nil
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
case action == requestlog.ActionBlockedByBlockRule ||
action == requestlog.ActionWAFBlocked:
return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionTooLarge:
case action == requestlog.ActionRequestHeaderTooLarge:
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
default:
return rq.banResponse(action)
@@ -303,15 +304,15 @@ func timing(start, end time.Time) *float64 {
// when it comes.
func (rq *request) addToHistory() {
request := ratelimit.Request{
Forwarded: rq.allowed,
Refused: rq.refused != nil,
Status: rq.out.status,
RequestBytes: rq.requestBytes,
ResponseBytes: rq.out.bytes,
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
Attack: rq.attack,
RuleBlocked: rq.ruleBlocked,
WAFBlocked: rq.wafBlocked,
Forwarded: rq.allowed,
Refused: rq.refused != nil,
Status: rq.out.status,
RequestBytes: rq.requestBytes,
ResponseBytes: rq.out.bytes,
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
Attack: rq.attack,
BlockedByBlockRule: rq.blockedByBlockRule,
WAFBlocked: rq.wafBlocked,
}
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
+10 -7
View File
@@ -16,9 +16,10 @@ import (
// ruleFiles checks the request against the rules of the rule files at now,
// notes the ids of those it matches in the log line, adds the tag
// exploitsignature when it matches any, and refuses the request
// when the last rule it matches refuses it: with ActionRuleBlocked for a block
// rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock
// for a clear sign of attack, or in observe mode raises the alert for the ban
// when the last rule it matches refuses it: with ActionBlockedByBlockRule for a
// block rule, and for a ban rule with the action for the ban it makes. A ban
// rule bans the client's netblock for a clear sign of attack, or in observe
// mode raises the alert for the ban
// it would have made. Either rule's match is noted as an offence, for the
// client's history. Its figures are the rule's id and action.
type ruleFiles struct{}
@@ -45,14 +46,16 @@ func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
switch last.Action {
case rules.ActionBlock:
rq.ruleBlocked = true
rq.blockedByBlockRule = true
return &verdict{action: requestlog.ActionRuleBlocked, figures: figures}
return &verdict{action: requestlog.ActionBlockedByBlockRule, figures: figures}
case rules.ActionBan:
rq.attack = true
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
return &verdict{action: requestlog.ActionBanned, figures: figures}
return &verdict{
action: rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target}),
figures: figures,
}
default:
return nil
}
+24 -18
View File
@@ -37,13 +37,14 @@ func TestEachRuleAction(t *testing.T) {
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
// bans no one.
line = s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked)
requestlog.ActionBlockedByBlockRule)
wantRuleIDs(t, line, "noted", "blocked")
s.get(client, http.StatusOK, requestlog.ActionForward)
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
// seven days, the default.
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
line = s.request(client, "/.env", http.StatusTooManyRequests,
requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line, "noted", "probe")
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
@@ -82,7 +83,7 @@ func TestEachRuleAction(t *testing.T) {
// against the rules, and makes the ban permanent.
clk.advance(time.Hour)
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
wantRuleIDs(t, line)
if line.BanExpires != permanent {
@@ -90,7 +91,7 @@ func TestEachRuleAction(t *testing.T) {
}
clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
}
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
@@ -103,7 +104,7 @@ func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
"exploitsignature")
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked), "exploitsignature")
requestlog.ActionBlockedByBlockRule), "exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
}
@@ -115,10 +116,11 @@ func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
})
line := s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked)
requestlog.ActionBlockedByBlockRule)
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
}
@@ -131,7 +133,8 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
})
// The first probe bans for SWW_ATTACK_BAN_DURATION.
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
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)
}
@@ -141,7 +144,8 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
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)
}
@@ -169,7 +173,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
// A probe over the rate limit breaks the limit before any rule sees
// it.
s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited)
line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
wantRuleIDs(t, line)
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
@@ -179,7 +184,7 @@ 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.ActionBanned)
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
@@ -191,11 +196,11 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
})
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
wantRuleIDs(t, line, "blocked")
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line, "probe")
if line.BanExpires != "" {
@@ -219,9 +224,10 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
metricsToken: token,
})
s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked)
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule)
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
metrics := s.scrape()
wantMetric(t, metrics,
@@ -229,10 +235,10 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
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="rule_blocked",`+
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="rule_blocked"}`, 1)
`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)
+8 -8
View File
@@ -68,7 +68,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
})
}
@@ -92,7 +92,7 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
wantStatus(t, do(t, req), http.StatusForbidden)
line := out.endedLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientDenied)
if len(asked()) != 0 {
t.Errorf("GeoJS was asked about %v, want nothing", asked())
@@ -108,7 +108,7 @@ func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
}
@@ -125,10 +125,10 @@ func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
// listedAddr's refused requests are not counted, so the first request
// from unlistedAddr is within the limit of one a minute.
wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
})
}
@@ -152,8 +152,8 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
{fromKP, http.StatusForbidden, requestlog.ActionDenied},
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
{fromKP, http.StatusForbidden, requestlog.ActionClientDenied},
})
}
+1 -1
View File
@@ -20,7 +20,7 @@ func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, tag := range rq.line.Tags {
if slices.Contains(rq.h.config.TagDeny, tag) {
return &verdict{
action: requestlog.ActionDenied,
action: requestlog.ActionClientDenied,
figures: map[string]any{"tag": tag},
}
}
+6 -6
View File
@@ -14,7 +14,7 @@ func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
// in capitals.
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
wantTags(t, line, "asn=64511", "country=KP")
@@ -26,7 +26,7 @@ func TestTagDenyRefusesAnASNTag(t *testing.T) {
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "asn=64511"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
@@ -41,7 +41,7 @@ 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.ActionDenied),
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "torexit"}`)
}
}
@@ -58,7 +58,7 @@ func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
})
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
}
@@ -69,7 +69,7 @@ func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
})
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
wantTags(t, line, "exploitsignature")
}
@@ -87,7 +87,7 @@ func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
// 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.ActionRateLimited)
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
"25 from "+tagLimitPercent)
}
+3 -3
View File
@@ -80,7 +80,7 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
})
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
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)
}
@@ -89,9 +89,9 @@ func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
// The second request breaks the limit of one a minute, which bans the client.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
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)
}
+1 -3
View File
@@ -8,7 +8,6 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
@@ -30,10 +29,9 @@ func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
}
rq.attack = true
rq.banForAttack(now, bans.Notes{TrapPath: path})
return &verdict{
action: requestlog.ActionBanned,
action: rq.banForAttack(now, bans.Notes{TrapPath: path}),
figures: map[string]any{"path": path},
}
}
+8 -6
View File
@@ -43,7 +43,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
// The query is not part of the path.
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line)
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
@@ -77,7 +77,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
}
// The next request is refused under the ban, and makes it permanent.
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
}
@@ -89,7 +89,7 @@ func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
requestlog.ActionBanned)
requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
}
@@ -100,7 +100,8 @@ func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
// %2577 decodes once to %77, not to w.
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
s.request(client, "/%77p-login.php", http.StatusForbidden, requestlog.ActionBanned)
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"))
@@ -117,7 +118,8 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
"SWW_RULES_ENABLED": "false",
})
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
s.request(client, "/xmlrpc.php", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
}
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
@@ -129,7 +131,7 @@ func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
})
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned)
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
// No ban was made.
s.get(client, http.StatusOK, requestlog.ActionForward)
+4 -4
View File
@@ -424,10 +424,10 @@ func (m *Metrics) LimitHit(hit ratelimit.Hit) {
// them.
func (m *Metrics) Offences(r ratelimit.Request) {
for kind, committed := range map[string]bool{
"limit": r.BrokeLimit,
"attack": r.Attack,
"rule_blocked": r.RuleBlocked,
"waf_blocked": r.WAFBlocked,
"limit": r.BrokeLimit,
"attack": r.Attack,
"blocked_by_block_rule": r.BlockedByBlockRule,
"waf_blocked": r.WAFBlocked,
} {
if committed {
m.offences.WithLabelValues(kind).Inc()
+14 -14
View File
@@ -122,12 +122,12 @@ type Responses struct {
type Offences struct {
// Limit is its requests that broke a rate limit, a byte limit or the
// error burst, Attack those that were a clear sign of attack, a match
// of a ban rule or a request for a trap path, RuleBlocked those a block
// rule refused, and WAFBlocked those the Core Rule Set refused.
Limit int64 `json:"limit"`
Attack int64 `json:"attack"`
RuleBlocked int64 `json:"rule_blocked"`
WAFBlocked int64 `json:"waf_blocked"`
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
// block rule refused, and WAFBlocked those the Core Rule Set refused.
Limit int64 `json:"limit"`
Attack int64 `json:"attack"`
BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
WAFBlocked int64 `json:"waf_blocked"`
// TokenRefused is what a clients.json written by an earlier version gives,
// which must still load. Nothing adds to it.
TokenRefused int64 `json:"token_refused,omitempty"`
@@ -147,12 +147,12 @@ type Request struct {
ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the error burst, Attack for one that matched a ban rule or
// asked for a trap path, RuleBlocked for one a block rule refused, and
// WAFBlocked for one the Core Rule Set refused.
BrokeLimit bool
Attack bool
RuleBlocked bool
WAFBlocked bool
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
// and WAFBlocked for one the Core Rule Set refused.
BrokeLimit bool
Attack bool
BlockedByBlockRule bool
WAFBlocked bool
}
// New returns a Limiter for limits, with no client counted yet, whose
@@ -323,8 +323,8 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
h.Offences.Attack++
}
if r.RuleBlocked {
h.Offences.RuleBlocked++
if r.BlockedByBlockRule {
h.Offences.BlockedByBlockRule++
}
if r.WAFBlocked {
+24 -19
View File
@@ -20,32 +20,37 @@ const (
// the app.
ActionForward = "forward"
// ActionTooLarge is a request over the header limits.
ActionTooLarge = "too_large"
// ActionRequestHeaderTooLarge is a request over the header limits.
ActionRequestHeaderTooLarge = "request_header_too_large"
// ActionRateLimited is a request refused because it took its client
// over a rate limit, which bans the client.
ActionRateLimited = "rate_limited"
// ActionClientIsRateLimited is a request refused because it took its
// client over a rate limit, which bans the client.
ActionClientIsRateLimited = "client_is_rate_limited"
// ActionBanned is a request refused because a ban covers its client,
// or because it matched a ban rule, asked for a trap path or the
// CrowdSec decision list lists its client, each of which bans the
// client.
ActionBanned = "banned"
// ActionTemporarilyBanned is a request refused because a ban that runs
// out covers its client, or because it matched a ban rule, asked for a
// trap path or the CrowdSec decision list lists its client, and so made
// such a ban.
ActionTemporarilyBanned = "temporarily_banned"
// ActionRuleBlocked is a request refused because it matched a block
// rule.
ActionRuleBlocked = "rule_blocked"
// ActionPermanentlyBanned is a request refused because a ban that never
// runs out covers its client, or because the request made such a ban or
// made its client's ban permanent.
ActionPermanentlyBanned = "permanently_banned"
// ActionBlockedByBlockRule is a request refused because it matched a
// block rule.
ActionBlockedByBlockRule = "blocked_by_block_rule"
// ActionWAFBlocked is a request refused because the Core Rule Set
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
ActionWAFBlocked = "waf_blocked"
// ActionDenied is a request refused because its client is in
// ActionClientDenied is a request refused because its client is in
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
ActionDenied = "denied"
ActionClientDenied = "client_denied"
// ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
@@ -134,10 +139,10 @@ type Line struct {
// The decision.
Action string `json:"action"`
// WouldAction is, in observe mode, the action enforce mode would have
// taken with a request it would have refused: ActionDenied,
// ActionBanned, ActionCountryDenied, ActionRateLimited,
// ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
// for the header limits.
// taken with a request it would have refused: ActionClientDenied,
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
// ActionClientIsRateLimited, ActionBlockedByBlockRule, ActionWAFBlocked,
// or, in discriminator, ActionRequestHeaderTooLarge for the header limits.
WouldAction string `json:"would_action,omitempty"`
// Heuristic is the name of the heuristic that refused the request, in
// observe mode too, and HeuristicFigures are the figures it acted on, by
+6 -5
View File
@@ -373,13 +373,13 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
out := runUntilStopped(t, env, func(url string) {
wantRefused(t, url)
})
out.line(t, "action", "rate_limited")
out.line(t, "action", "client_is_rate_limited")
// After another, the ban still refuses it.
out = runUntilStopped(t, env, func(url string) {
wantRefused(t, url)
})
out.line(t, "action", "banned")
out.line(t, "action", "temporarily_banned")
}
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
@@ -526,7 +526,7 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
time.Sleep(pollInterval)
}
})
out.line(t, "action", "rule_blocked")
out.line(t, "action", "blocked_by_block_rule")
}
func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
@@ -740,7 +740,7 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
time.Sleep(pollInterval)
}
})
wantDeniedByList(t, out.line(t, "action", "denied"), dropURL)
wantDeniedByList(t, out.line(t, "action", "client_denied"), dropURL)
if fetches := torFetches.Load(); fetches != 1 {
t.Errorf("tor.txt fetched %d times, want once, before the restart", fetches)
@@ -843,7 +843,8 @@ func wantDeniedByList(t *testing.T, line map[string]any, listURL string) {
t.Helper()
reputation, _ := line["reputation"].([]any)
if line["action"] != "denied" || len(reputation) != 1 || reputation[0] != listURL {
if line["action"] != "client_denied" || len(reputation) != 1 ||
reputation[0] != listURL {
t.Errorf("request log line %v, want one denied for %s", line, listURL)
}
}