package proxy_test import ( "net/http" "net/netip" "os" "path/filepath" "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 || 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() const scraper = "192.0.2.200" 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(scraper) 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_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) } }