check / check (push) Waiting to run
Each check servewaf runs on a request is a type in its own file behind one interface, heuristic, and is registered in one of four ordered lists in heuristics.go. Order and behaviour are unchanged. Every refusal now logs heuristic, the name of the heuristic that made it, and heuristic_figures, the figures it acted on. A ban by refusal_limit or byte_limits after the answer logs after_answer_heuristic and after_answer_heuristic_figures instead, so a line can name both. All four are in docs/request-log.md, and SPEC.md names each heuristic at its step. Model: opus-5-5
141 lines
4.1 KiB
Go
141 lines
4.1 KiB
Go
package servewaf_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, the whole of it in the same case, is a trap
|
|
// path.
|
|
for _, path := range []string{"/wp-login.php/", "/WP-LOGIN.PHP", "/blog/xmlrpc.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 TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
|
|
|
|
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
|
|
requestlog.ActionBanned)
|
|
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
|
|
}
|
|
|
|
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
|
|
|
|
// %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)
|
|
|
|
// The ban's notes give the path as the client sent it.
|
|
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
|
if len(got) != 1 || got[0].Notes.TrapPath != "/%77p-login.php" {
|
|
t.Fatalf("bans %+v, want one whose notes give the trap path /%%77p-login.php", got)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|