check / check (push) Waiting to run
The package passes nothing to an app since rproxy took that over, so it is named after the command it serves. The rproxy tests call their stand-in for servewaf standIn, since a local servewaf would hide the package. No change of behaviour. Model: opus-5-5
236 lines
7.3 KiB
Go
236 lines
7.3 KiB
Go
package servewaf_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// testRules are the rules most tests here load: a block rule for
|
|
// /blocked and a ban rule for /.env.
|
|
const testRules = `
|
|
blocked path block ^/blocked$
|
|
probe path ban ^/\.env$
|
|
`
|
|
|
|
func TestEachRuleAction(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, server := startWithClock(t, "", map[string]string{
|
|
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
|
banResponse: "429",
|
|
})
|
|
start := clk.Now()
|
|
|
|
// A log rule notes its match, and lets the request through.
|
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
wantRuleIDs(t, line, "noted")
|
|
|
|
// A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and
|
|
// bans no one.
|
|
line = s.request(client, "/blocked", http.StatusForbidden,
|
|
requestlog.ActionRuleBlocked)
|
|
wantRuleIDs(t, line, "noted", "blocked")
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
// A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for
|
|
// seven days, the default.
|
|
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
|
wantRuleIDs(t, line, "noted", "probe")
|
|
|
|
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
|
t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires)
|
|
}
|
|
|
|
netblock := netip.MustParsePrefix(client + "/32")
|
|
want := bans.Ban{
|
|
Netblock: netblock,
|
|
Start: start,
|
|
Expires: start.Add(7 * 24 * time.Hour),
|
|
Cause: bans.CauseAttack,
|
|
Reason: "matched the rule probe",
|
|
Notes: bans.Notes{
|
|
RuleID: "probe",
|
|
Target: "path",
|
|
Request: bans.Request{
|
|
Time: start,
|
|
Method: http.MethodGet,
|
|
Host: appHost,
|
|
Path: "/.env",
|
|
Status: http.StatusTooManyRequests,
|
|
UserAgent: userAgent,
|
|
},
|
|
// The four requests up to and including the probe.
|
|
Requests: 4,
|
|
},
|
|
}
|
|
|
|
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, without being checked
|
|
// against the rules, and makes the ban permanent.
|
|
clk.advance(time.Hour)
|
|
|
|
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
|
wantRuleIDs(t, line)
|
|
|
|
if line.BanExpires != permanent {
|
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
|
}
|
|
|
|
clk.advance(365 * 24 * time.Hour)
|
|
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
|
}
|
|
|
|
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
|
rulesDir: writeRules(t, testRules),
|
|
attackBanDuration: "1h",
|
|
})
|
|
|
|
// The first probe bans for SWWAF_ATTACK_BAN_DURATION.
|
|
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
|
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
|
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
|
}
|
|
|
|
// Once that ban has run out without a request, the client is served,
|
|
// and its next probe bans it for good.
|
|
clk.advance(time.Hour)
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
|
if line.BanExpires != permanent {
|
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
|
}
|
|
}
|
|
|
|
func TestRulesComeAfterTheOtherChecks(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, _, server := startWithClock(t, "", map[string]string{
|
|
rulesDir: writeRules(t, testRules),
|
|
allowNets: allowed,
|
|
rateLimitExemptNets: exempt,
|
|
rateLimitPerMinute: "1",
|
|
})
|
|
|
|
// A client in SWWAF_ALLOW_NETS is not checked.
|
|
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
|
wantRuleIDs(t, line)
|
|
|
|
// A probe over the rate limit breaks the limit before any rule sees
|
|
// it.
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited)
|
|
wantRuleIDs(t, line)
|
|
|
|
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
|
if len(limitBan) != 1 || limitBan[0].Cause != bans.CauseLimit {
|
|
t.Errorf("bans %+v, want one for a broken limit", limitBan)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{
|
|
rulesDir: writeRules(t, testRules),
|
|
mode: observe,
|
|
})
|
|
|
|
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
|
|
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
|
wantRuleIDs(t, line, "blocked")
|
|
|
|
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
|
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
|
wantRuleIDs(t, line, "probe")
|
|
|
|
if line.BanExpires != "" {
|
|
t.Errorf("log line has ban_expires %q, want none", line.BanExpires)
|
|
}
|
|
|
|
// No ban was made.
|
|
line = s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
wantWouldAction(t, line, "")
|
|
|
|
if got := server.Ledger.Snapshot(); len(got) != 0 {
|
|
t.Errorf("bans %+v, want none", got)
|
|
}
|
|
}
|
|
|
|
func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{
|
|
rulesDir: writeRules(t, testRules),
|
|
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)
|
|
|
|
metrics := s.scrape()
|
|
wantMetric(t, metrics,
|
|
`smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
|
wantMetric(t, metrics,
|
|
`smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
|
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)
|
|
}
|
|
|
|
// writeRules writes content as a rule file into a new directory, and
|
|
// returns the directory.
|
|
func writeRules(t *testing.T, content string) string {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
|
|
err := os.WriteFile(filepath.Join(dir, "test.rules"), []byte(content), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write the rule file: %v", err)
|
|
}
|
|
|
|
return dir
|
|
}
|
|
|
|
// wantRuleIDs checks the request log line's rule_ids.
|
|
func wantRuleIDs(t *testing.T, line logLine, want ...string) {
|
|
t.Helper()
|
|
|
|
if !slices.Equal(line.RuleIDs, want) {
|
|
t.Errorf("log line has rule_ids %v, want %v", line.RuleIDs, want)
|
|
}
|
|
}
|