check / check (push) Waiting to run
script/fmt now runs golines -m 77 after gofmt, repeating golines until it changes nothing, since one pass can leave a line the next splits. script/fmt-check fails when either would change a file. script/bootstrap installs golines v0.13.0 with go install, pinned to its commit, and checks the installed version with go version -m. The tree is reformatted. The longer lines this makes are fixed by hand: nolint directives moved onto their own line above the statement, one string split, and 20 functions over funlen's 80 lines shortened or split, RunWAF and limitPercentages among them. Model: opus-5-5
187 lines
4.3 KiB
Go
187 lines
4.3 KiB
Go
package waf_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 = "SWW_TRAP_PATHS"
|
|
trapPathList = "/wp-login.php,/xmlrpc.php"
|
|
)
|
|
|
|
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const allowed = "192.0.2.60" // in SWW_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 SWW_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.ActionTemporarilyBanned,
|
|
)
|
|
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.ActionPermanentlyBanned,
|
|
)
|
|
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.ActionTemporarilyBanned,
|
|
)
|
|
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.ActionTemporarilyBanned)
|
|
|
|
// 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,
|
|
"SWW_RULES_ENABLED": "false",
|
|
})
|
|
|
|
s.request(client, "/xmlrpc.php", http.StatusForbidden,
|
|
requestlog.ActionTemporarilyBanned)
|
|
}
|
|
|
|
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.ActionTemporarilyBanned)
|
|
|
|
// 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)
|
|
}
|
|
}
|