check / check (push) Failing after 3m27s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Every place a name appeared changed: the code, the viper keys, tests, documents, examples, the README's settings table and the image files. Model: deepseek-v4-pro
236 lines
7.2 KiB
Go
236 lines
7.2 KiB
Go
package discriminator_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 SWW_WAF_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 SWW_WAF_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 SWW_WAF_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 SWW_WAF_ALLOW_NETS
|
|
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
|
)
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{
|
|
rulesDir: writeRules(t, testRules),
|
|
allowNets: allowed,
|
|
rateLimitExemptNets: exempt,
|
|
rateLimitPerMinute: "1",
|
|
})
|
|
|
|
// A client in SWW_WAF_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,
|
|
`sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
|
wantMetric(t, metrics,
|
|
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
|
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
|
|
wantMetric(t, metrics, `sww_requests_total{action="rule_blocked",`+
|
|
`instance="app",status_class="4xx"}`, 1)
|
|
wantMetric(t, metrics,
|
|
`sww_offences_total{instance="app",kind="rule_blocked"}`, 1)
|
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
|
|
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
|
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
|
|
wantMetric(t, metrics, `sww_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)
|
|
}
|
|
}
|