Trap paths, and the error burst banning a client refused too often (closes #115)
check / check (push) Waiting to run
check / check (push) Waiting to run
SWWAF_TRAP_PATHS: a request whose path, as a path rule sees it, is one of them is a clear sign of attack, banned as a ban rule's match is; the ban's notes give its trap_path. Checked after the rate limits, before the rule files. SWWAF_ERROR_BURST_THRESHOLD (default 30, or off): more refusals in a minute after a block or ban rule or a trap path, or for a missing or wrong token, ban the client as a broken limit does. Counted in clients.json's minute_refusals; limit_hit error_burst, notes kind refusals. A token refusal is now the offence token_refused, and smallwebwaf_offences_total counts every kind the history does. Judgement call: the threshold is not lowered by a client's limit percentage. Model: opus-5-5
This commit is contained in:
@@ -45,8 +45,9 @@ var (
|
||||
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
||||
// token, sent as Authorization: Bearer <token>: the metrics
|
||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without
|
||||
// it is refused with 401. An endpoint whose token is unset answers 404,
|
||||
// as any other request under /_smallwebwaf/ does.
|
||||
// it is refused with 401, which counts toward the error burst. An
|
||||
// endpoint whose token is unset answers 404, as any other request under
|
||||
// /_smallwebwaf/ does.
|
||||
func (rq *request) answerAdmin() {
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
rq.startClientResponseTimeout()
|
||||
@@ -57,6 +58,7 @@ func (rq *request) answerAdmin() {
|
||||
case token == "":
|
||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
case !hasToken(rq.in, token):
|
||||
rq.tokenRefused = true
|
||||
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
||||
rq.answer(refusal{
|
||||
status: http.StatusUnauthorized,
|
||||
|
||||
+76
-30
@@ -1,6 +1,7 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
@@ -9,7 +10,6 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
||||
@@ -83,6 +83,48 @@ func (rq *request) countBytes() {
|
||||
}
|
||||
}
|
||||
|
||||
// countRefusal counts the request for the error burst once it has been
|
||||
// answered, if smallwebwaf refused it after a rule file match or a trap
|
||||
// path, or for a missing or wrong token, and in observe mode if enforce
|
||||
// mode would have: more than SWWAF_ERROR_BURST_THRESHOLD such refusals of
|
||||
// the client within a minute break a limit. A client in SWWAF_ALLOW_NETS,
|
||||
// which the checks skip, is not counted, and nothing is while the
|
||||
// threshold is off.
|
||||
func (rq *request) countRefusal() {
|
||||
cfg := rq.h.config
|
||||
if cfg.ErrorBurstThreshold == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// In observe mode, a request that enforce mode would have refused
|
||||
// before it reached the endpoint has had no token refused there.
|
||||
tokenRefused := rq.tokenRefused && rq.line.WouldAction == "" &&
|
||||
!isInside(rq.client, cfg.AllowNets)
|
||||
if !rq.attack && !rq.ruleBlocked && !tokenRefused {
|
||||
return
|
||||
}
|
||||
|
||||
now := rq.h.now()
|
||||
|
||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
||||
cfg.ErrorBurstThreshold)
|
||||
if !over {
|
||||
return
|
||||
}
|
||||
|
||||
// What the client was sent, or in observe mode would have been.
|
||||
status := rq.out.status
|
||||
|
||||
switch rq.line.WouldAction {
|
||||
case requestlog.ActionRuleBlocked:
|
||||
status = http.StatusForbidden
|
||||
case requestlog.ActionBanned:
|
||||
status = cfg.BanResponse
|
||||
}
|
||||
|
||||
rq.banForLimit(now, hit, status)
|
||||
}
|
||||
|
||||
// countedBytes returns the request's bytes, once it has ended, as the
|
||||
// byte limits and the anomaly thresholds count them: the response's body
|
||||
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says. For an
|
||||
@@ -107,20 +149,27 @@ func (rq *request) countedBytes() int64 {
|
||||
}
|
||||
|
||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||
// one hit names, and notes the offence for the log line. status is what
|
||||
// the client was sent, or is sent: SWWAF_BAN_RESPONSE for a request over
|
||||
// a rate limit, the app's answer for one whose bytes broke a byte limit.
|
||||
// The ban's notes give the client's limit percentage for that kind of
|
||||
// limit. 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.
|
||||
// one hit names, notes the offence for the log line and counts the hit in
|
||||
// the metrics. status is what the client was sent, or is sent:
|
||||
// SWWAF_BAN_RESPONSE for a request over a rate limit, the app's answer for
|
||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||
// error burst. The ban's notes give the client's limit percentage for a
|
||||
// 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) {
|
||||
rq.line.LimitHit = hit.Window
|
||||
if hit.Kind == ratelimit.KindBytes {
|
||||
rq.line.LimitHit += "_bytes" // as counts names the byte totals
|
||||
switch hit.Kind {
|
||||
case ratelimit.KindBytes:
|
||||
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
||||
case ratelimit.KindRefusals:
|
||||
rq.line.LimitHit = requestlog.LimitHitErrorBurst
|
||||
default:
|
||||
rq.line.LimitHit = hit.Window
|
||||
}
|
||||
|
||||
rq.line.Offence = requestlog.OffenceLimit
|
||||
rq.h.metrics.LimitHit(hit)
|
||||
|
||||
netblock := rq.h.netblock(rq.client)
|
||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
||||
@@ -140,13 +189,13 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||
Requests: rq.netblockRequests(netblock),
|
||||
}
|
||||
|
||||
percent := rq.limitPercent
|
||||
if hit.Kind == ratelimit.KindBytes {
|
||||
percent = rq.bytesPercent
|
||||
switch hit.Kind {
|
||||
case ratelimit.KindRequests:
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged()
|
||||
case ratelimit.KindBytes:
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged()
|
||||
}
|
||||
|
||||
notes.LimitPercent, notes.LimitPercentSetting = percent.logged()
|
||||
|
||||
if rq.h.config.Observe {
|
||||
ban, wouldBan := rq.h.ledger.WouldBanForLimit(netblock, now, notes)
|
||||
if wouldBan {
|
||||
@@ -166,25 +215,22 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||
}
|
||||
|
||||
// banForAttack bans the client's netblock at now for a clear sign of
|
||||
// attack, the match of rule, a ban rule. 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, rule rules.Rule) {
|
||||
// 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) {
|
||||
netblock := rq.h.netblock(rq.client)
|
||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
||||
return
|
||||
}
|
||||
|
||||
notes := bans.Notes{
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
RuleID: rule.ID,
|
||||
Target: rule.Target,
|
||||
Reputation: rq.reputation,
|
||||
Request: rq.noted(now, rq.h.config.BanResponse),
|
||||
Requests: rq.netblockRequests(netblock),
|
||||
}
|
||||
notes.ASN = rq.line.ASN
|
||||
notes.ASName = rq.line.ASName
|
||||
notes.Country = rq.line.Country
|
||||
notes.Reputation = rq.reputation
|
||||
notes.Request = rq.noted(now, rq.h.config.BanResponse)
|
||||
notes.Requests = rq.netblockRequests(netblock)
|
||||
|
||||
if rq.h.config.Observe {
|
||||
ban, wouldBan := rq.h.ledger.WouldBanForAttack(netblock, now, notes)
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||
|
||||
// refused is a request the tests here send, which smallwebwaf refuses
|
||||
// after a rule file match, or for a missing or wrong token.
|
||||
type refused int
|
||||
|
||||
const (
|
||||
// blockRule is a request testRules' block rule refuses with 403.
|
||||
blockRule refused = iota
|
||||
// banRule is one its ban rule refuses with 403, and bans the client
|
||||
// for.
|
||||
banRule
|
||||
// noMetricsToken is one for the metrics without a token, and
|
||||
// wrongAdminToken one for the bans with the metrics token, each
|
||||
// refused with 401.
|
||||
noMetricsToken
|
||||
wrongAdminToken
|
||||
)
|
||||
|
||||
// send sends r from the client at from, checks its answer and log line as
|
||||
// sender.request does, and returns the line.
|
||||
func (r refused) send(s *sender, from string) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
switch r {
|
||||
case blockRule:
|
||||
return s.request(from, blockedPath, http.StatusForbidden,
|
||||
requestlog.ActionRuleBlocked)
|
||||
case banRule:
|
||||
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
||||
case noMetricsToken:
|
||||
return s.request(from, proxy.MetricsPath, http.StatusUnauthorized,
|
||||
requestlog.ActionAdmin)
|
||||
case wrongAdminToken:
|
||||
line, _ := s.requestWithHeader(from, proxy.BansPath, "Authorization: "+bearer,
|
||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
s.t.Fatalf("no request for the refusal %d", r)
|
||||
|
||||
return logLine{}
|
||||
}
|
||||
|
||||
// startForErrorBurst is startWithClock with testRules, both tokens and
|
||||
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||
func startForErrorBurst(
|
||||
t *testing.T, threshold string, env map[string]string,
|
||||
) (*sender, *clock, *proxy.Server) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
errorBurstThreshold: threshold,
|
||||
rulesDir: writeRules(t, testRules),
|
||||
adminToken: adminSecret,
|
||||
metricsToken: token,
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
return startWithClock(t, "", settings)
|
||||
}
|
||||
|
||||
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// refusals are four, one over the threshold of three.
|
||||
refusals []refused
|
||||
}{
|
||||
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
|
||||
{
|
||||
"missing or wrong token",
|
||||
[]refused{noMetricsToken, wrongAdminToken, noMetricsToken, wrongAdminToken},
|
||||
},
|
||||
{
|
||||
"a mix ending in a ban rule",
|
||||
[]refused{blockRule, noMetricsToken, blockRule, banRule},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startForErrorBurst(t, "3", nil)
|
||||
|
||||
// Three refusals break nothing, and the app's answers between
|
||||
// them are not counted.
|
||||
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)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// The fourth is answered as the others were, breaks the error
|
||||
// burst, and bans the client.
|
||||
line := tc.refusals[3].send(s, client)
|
||||
if line.LimitHit != requestlog.LimitHitErrorBurst ||
|
||||
line.Offence != requestlog.OffenceLimit {
|
||||
t.Errorf("log line has limit_hit %q and offence %q, want error_burst "+
|
||||
"and limit", line.LimitHit, line.Offence)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scraper = "192.0.2.200"
|
||||
|
||||
s, clk, server := startForErrorBurst(t, "2", nil)
|
||||
start := clk.Now()
|
||||
|
||||
blockRule.send(s, client)
|
||||
wrongAdminToken.send(s, client)
|
||||
line := blockRule.send(s, client)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
netblock := netip.MustParsePrefix(client + "/32")
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: start,
|
||||
Expires: expires,
|
||||
Cause: bans.CauseLimit,
|
||||
Reason: "refusals per minute over the limit of 2",
|
||||
Notes: bans.Notes{
|
||||
Kind: ratelimit.KindRefusals,
|
||||
Limit: 2,
|
||||
Window: minute,
|
||||
Count: 3,
|
||||
Request: bans.Request{
|
||||
Time: start,
|
||||
Method: http.MethodGet,
|
||||
Host: appHost,
|
||||
Path: blockedPath,
|
||||
Status: http.StatusForbidden,
|
||||
UserAgent: userAgent,
|
||||
},
|
||||
Requests: 3,
|
||||
},
|
||||
}
|
||||
|
||||
got := server.Ledger.Bans(netblock)
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 2, TokenRefused: 1}
|
||||
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
||||
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
||||
}
|
||||
|
||||
metrics := s.scrape(scraper)
|
||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||
`kind="refusals",window="minute"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 2)
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_offences_total{instance="app",kind="token_refused"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||
}
|
||||
|
||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||
errorBurstThreshold: "2",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
countryLimitPercent: countryDEHalf,
|
||||
})
|
||||
|
||||
// Half of the threshold would be one, which the second refusal is over.
|
||||
for range 2 {
|
||||
line := blockRule.send(s, fromDE)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
statuses := map[string]int{
|
||||
"/missing": http.StatusNotFound,
|
||||
"/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)})
|
||||
|
||||
for range 2 {
|
||||
for path, status := range statuses {
|
||||
s.request(client, path, status, requestlog.ActionForward)
|
||||
}
|
||||
}
|
||||
|
||||
// The first refusal is one, not over the threshold.
|
||||
line := blockRule.send(s, client)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
|
||||
// No ban was made, nor its alert raised.
|
||||
s.request(client, "/missing", http.StatusNotFound, requestlog.ActionForward)
|
||||
wantAlerts(t, queue)
|
||||
}
|
||||
|
||||
func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const off = "off"
|
||||
|
||||
for _, tc := range []struct {
|
||||
threshold string
|
||||
// broken is whether the 31st refusal breaks the error burst.
|
||||
broken bool
|
||||
}{
|
||||
{"", true},
|
||||
{off, false},
|
||||
} {
|
||||
t.Run(errorBurstThreshold+"="+tc.threshold, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{rulesDir: writeRules(t, testRules)}
|
||||
if tc.threshold != "" {
|
||||
env[errorBurstThreshold] = tc.threshold
|
||||
}
|
||||
|
||||
s, _, _ := startWithClock(t, "", env)
|
||||
|
||||
var line logLine
|
||||
for range 31 {
|
||||
line = blockRule.send(s, client)
|
||||
}
|
||||
|
||||
if broken := line.LimitHit == requestlog.LimitHitErrorBurst; broken != tc.broken {
|
||||
t.Errorf("the 31st refusal broke the error burst: %t, want %t",
|
||||
broken, tc.broken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
||||
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||
})
|
||||
|
||||
// A client in SWWAF_ALLOW_NETS still needs the token, but is not
|
||||
// counted.
|
||||
for range 3 {
|
||||
line := noMetricsToken.send(s, allowed)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
}
|
||||
|
||||
// One the rate limits do not apply to is.
|
||||
noMetricsToken.send(s, exempt)
|
||||
|
||||
line := wrongAdminToken.send(s, exempt)
|
||||
if line.LimitHit != requestlog.LimitHitErrorBurst {
|
||||
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
||||
}
|
||||
|
||||
s.get(exempt, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startForErrorBurst(t, "1", map[string]string{limitBanDuration: "1s"})
|
||||
|
||||
blockRule.send(s, client)
|
||||
blockRule.send(s, client)
|
||||
|
||||
// Within the same minute, once the ban has ended, the next refusal is
|
||||
// the first again.
|
||||
clk.advance(time.Second)
|
||||
|
||||
line := blockRule.send(s, client)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
mode: observe,
|
||||
errorBurstThreshold: "1",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
adminToken: adminSecret,
|
||||
})
|
||||
start := clk.Now()
|
||||
held := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix(otherClient + "/32"),
|
||||
Start: start,
|
||||
Expires: start.Add(time.Hour),
|
||||
Cause: bans.CauseAdmin,
|
||||
}
|
||||
server.Ledger.Load([]bans.Ban{held})
|
||||
|
||||
// Under a ban, enforce mode would have refused these before the
|
||||
// endpoint, so their tokens are not counted.
|
||||
for range 2 {
|
||||
line := wrongAdminToken.send(s, otherClient)
|
||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
}
|
||||
|
||||
// The block rule's refusal, which enforce mode would have answered 403,
|
||||
// is the second of the client's, and would have banned it.
|
||||
wrongAdminToken.send(s, client)
|
||||
|
||||
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
||||
|
||||
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
|
||||
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
|
||||
"and none", line.LimitHit, line.BanExpires)
|
||||
}
|
||||
|
||||
if got := server.Ledger.Snapshot(); len(got) != 1 || !reflect.DeepEqual(got[0], held) {
|
||||
t.Errorf("bans %+v, want only the one held", got)
|
||||
}
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 1 {
|
||||
t.Fatalf("%d alerts wait, want 1: %+v", len(waiting), waiting)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
alert := banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||
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
|
||||
wantAlerts(t, queue, alert)
|
||||
}
|
||||
@@ -282,9 +282,12 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Once the request has ended, before its log line is written.
|
||||
// Once the request has ended, before its log line is written. The
|
||||
// last deferred runs first: countRefusal before addToHistory, so that
|
||||
// a broken error burst is in the client's history.
|
||||
defer rq.addToHistory()
|
||||
defer rq.countAnomalies()
|
||||
defer rq.countRefusal()
|
||||
|
||||
refused := rq.check(r.Context())
|
||||
rq.checked = time.Now()
|
||||
|
||||
@@ -701,10 +701,14 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing
|
||||
wantAbuseIPDBChecks(t, server, 1)
|
||||
}
|
||||
|
||||
// probePath is the path the ban rule of testRules, probe, matches.
|
||||
const probePath = "/.env"
|
||||
// probePath is the path the ban rule of testRules, probe, matches, and
|
||||
// blockedPath the one its block rule, blocked, matches.
|
||||
const (
|
||||
probePath = "/.env"
|
||||
blockedPath = "/blocked"
|
||||
)
|
||||
|
||||
func TestClientARuleRefusedIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T) {
|
||||
func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -718,13 +722,21 @@ func TestClientARuleRefusedIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T)
|
||||
want ratelimit.Offences
|
||||
}{
|
||||
{
|
||||
"a block rule", "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked,
|
||||
"a block rule", blockedPath, http.StatusForbidden, requestlog.ActionRuleBlocked,
|
||||
ratelimit.Offences{RuleBlocked: 1},
|
||||
},
|
||||
{
|
||||
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned,
|
||||
ratelimit.Offences{Attack: 1},
|
||||
},
|
||||
{
|
||||
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
|
||||
ratelimit.Offences{Attack: 1},
|
||||
},
|
||||
{
|
||||
"a missing token", proxy.MetricsPath, http.StatusUnauthorized,
|
||||
requestlog.ActionAdmin, ratelimit.Offences{TokenRefused: 1},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -732,6 +744,7 @@ func TestClientARuleRefusedIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T)
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
|
||||
trapPaths: trapPathList, metricsToken: token,
|
||||
})
|
||||
|
||||
s.request(client, tc.path, tc.status, tc.action)
|
||||
@@ -741,8 +754,8 @@ func TestClientARuleRefusedIsCheckedWithAbuseIPDBAtItsNextRequest(t *testing.T)
|
||||
t.Errorf("history counts the offences %+v, want %+v", got, tc.want)
|
||||
}
|
||||
|
||||
// Its next request, once a ban rule's ban has ended, has it
|
||||
// checked.
|
||||
// Its next request, once any ban for a clear sign of attack
|
||||
// has ended, has it checked.
|
||||
clk.advance(time.Hour)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantAbuseIPDBChecks(t, server, 1)
|
||||
|
||||
+23
-14
@@ -64,10 +64,11 @@ type request struct {
|
||||
// limits and for the byte limits.
|
||||
counted bool
|
||||
limitPercent, bytesPercent percentage
|
||||
// attack is true for a request that matched a ban rule, and
|
||||
// ruleBlocked for one a block rule refused, each an offence its
|
||||
// client's history counts.
|
||||
attack, ruleBlocked bool
|
||||
// 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
|
||||
// tokenRefused for one refused for a missing or wrong token, each an
|
||||
// offence its client's history counts.
|
||||
attack, ruleBlocked, tokenRefused bool
|
||||
// blocklisted is true once a blocklist is found to list the client,
|
||||
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
|
||||
// AbuseIPDB's score of it is a hit.
|
||||
@@ -232,9 +233,9 @@ func (rq *request) check(ctx context.Context) *refusal {
|
||||
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
||||
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
||||
// every other request is counted, each of them by the client's limit
|
||||
// percentages, and last the rule files. A request exempt from the rate
|
||||
// limits is exempt from the byte limits too. ctx is the request's own
|
||||
// context.
|
||||
// percentages, then SWWAF_TRAP_PATHS, and last the rule files. A request
|
||||
// exempt from the rate limits is exempt from the byte limits too. ctx is
|
||||
// the request's own context.
|
||||
func (rq *request) checkClient(ctx context.Context) string {
|
||||
cfg := rq.h.config
|
||||
if isInside(rq.client, cfg.AllowNets) {
|
||||
@@ -281,6 +282,10 @@ func (rq *request) checkClient(ctx context.Context) string {
|
||||
return requestlog.ActionRateLimited
|
||||
}
|
||||
|
||||
if rq.trapPath(now) {
|
||||
return requestlog.ActionBanned
|
||||
}
|
||||
|
||||
return rq.checkRules(now)
|
||||
}
|
||||
|
||||
@@ -541,14 +546,14 @@ func timing(start, end time.Time) *float64 {
|
||||
}
|
||||
|
||||
// addToHistory adds the request, which has ended, to its client's
|
||||
// history, and then the lookup's answer about the client, as
|
||||
// answerAtTheEnd gives it, to that history and to the notes of the bans
|
||||
// on its netblock: an answer may have come before either was there, and
|
||||
// one from GeoJS that comes later is added when it comes.
|
||||
// history, and counts its offences in the metrics, and then the lookup's
|
||||
// answer about the client, as answerAtTheEnd gives it, to that history and
|
||||
// to the notes of the bans on its netblock: an answer may have come
|
||||
// before either was there, and one from GeoJS that comes later is added
|
||||
// when it comes.
|
||||
func (rq *request) addToHistory() {
|
||||
forwarded := !rq.upstreamStart.IsZero()
|
||||
|
||||
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), ratelimit.Request{
|
||||
request := ratelimit.Request{
|
||||
Forwarded: forwarded,
|
||||
Refused: !forwarded && rq.refused.Load() != nil,
|
||||
Status: rq.out.status,
|
||||
@@ -557,7 +562,11 @@ func (rq *request) addToHistory() {
|
||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||
Attack: rq.attack,
|
||||
RuleBlocked: rq.ruleBlocked,
|
||||
})
|
||||
TokenRefused: rq.tokenRefused,
|
||||
}
|
||||
|
||||
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
|
||||
rq.h.metrics.Offences(request)
|
||||
|
||||
answer, found := rq.answerAtTheEnd()
|
||||
if found {
|
||||
|
||||
@@ -204,6 +204,9 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
||||
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2)
|
||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+
|
||||
`instance="app",status_class="4xx"}`, 1)
|
||||
wantMetric(t, metrics,
|
||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="attack"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1)
|
||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0)
|
||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
||||
|
||||
+20
-1
@@ -1,12 +1,31 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// trapPath reports whether the request asks for a path in
|
||||
// SWWAF_TRAP_PATHS: its path as a path rule sees it, before any decoding
|
||||
// and without the query, is one of them. Such a request is a clear sign of
|
||||
// attack, as a ban rule's match is: it bans the client's netblock, or in
|
||||
// observe mode raises the alert for the ban it would have made.
|
||||
func (rq *request) trapPath(now time.Time) bool {
|
||||
path := rules.Path(rq.in)
|
||||
if !slices.Contains(rq.h.config.TrapPaths, path) {
|
||||
return false
|
||||
}
|
||||
|
||||
rq.attack = true
|
||||
rq.banForAttack(now, bans.Notes{TrapPath: path})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// checkRules checks the request against the rules of the rule files at
|
||||
// now, notes the ids of those it matches in the log line, and returns the
|
||||
// action of the rule that refuses it, ActionRuleBlocked for a block rule
|
||||
@@ -34,7 +53,7 @@ func (rq *request) checkRules(now time.Time) string {
|
||||
return requestlog.ActionRuleBlocked
|
||||
case rules.ActionBan:
|
||||
rq.attack = true
|
||||
rq.banForAttack(now, last)
|
||||
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
|
||||
|
||||
return requestlog.ActionBanned
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package proxy_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// trapPaths is the setting's name, and trapPathList what the tests set it
|
||||
// to.
|
||||
const (
|
||||
trapPaths = "SWWAF_TRAP_PATHS"
|
||||
trapPathList = "/wp-login.php,/xmlrpc.php"
|
||||
)
|
||||
|
||||
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
||||
|
||||
// A block rule for the same path: the trap path comes first.
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
trapPaths: trapPathList,
|
||||
rulesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
|
||||
allowNets: allowed,
|
||||
banResponse: "429",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// Only the path itself, as the client sent it, is a trap path.
|
||||
for _, path := range []string{
|
||||
"/wp-login.php/", "/WP-LOGIN.PHP", "/blog/xmlrpc.php", "/%77p-login.php",
|
||||
} {
|
||||
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// A client in SWWAF_ALLOW_NETS is not checked.
|
||||
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.ActionBanned)
|
||||
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)
|
||||
}
|
||||
|
||||
netblock := netip.MustParsePrefix(client + "/32")
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: start,
|
||||
Expires: start.Add(7 * 24 * time.Hour),
|
||||
Cause: bans.CauseAttack,
|
||||
Reason: "asked for the trap path /wp-login.php",
|
||||
Notes: bans.Notes{
|
||||
TrapPath: "/wp-login.php",
|
||||
Request: bans.Request{
|
||||
Time: start,
|
||||
Method: http.MethodGet,
|
||||
Host: appHost,
|
||||
Path: "/wp-login.php?redirect_to=x",
|
||||
Status: http.StatusTooManyRequests,
|
||||
UserAgent: userAgent,
|
||||
},
|
||||
Requests: 1,
|
||||
},
|
||||
}
|
||||
|
||||
got := server.Ledger.Bans(netblock)
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
// The next request is refused under the ban, and makes it permanent.
|
||||
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||
if line.BanExpires != permanent {
|
||||
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
trapPaths: trapPathList,
|
||||
"SWWAF_RULES_ENABLED": "false",
|
||||
})
|
||||
|
||||
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
trapPaths: trapPathList,
|
||||
mode: observe,
|
||||
})
|
||||
|
||||
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||
|
||||
// No ban was made.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if got := server.Ledger.Snapshot(); len(got) != 0 {
|
||||
t.Errorf("bans %+v, want none", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user