Author SHA1 Message Date
sneak 2bfdea3f66 Core Rule Set tests: WordPress's recorded requests go through the shared check (closes #284)
check / check (push) Failing after 3m14s
WordPress's recorded requests now go through wantRecordedRequestsPass,
so a request with no body but a Content-Type is also inspected while
bodies are read, as every other app's is. The seven such requests,
wp-cron.php's, pass; wordpress.conf is unchanged.

Model: opus-5-5
2026-11-10 10:59:31 +00:00
+13 -104
View File
@@ -625,12 +625,11 @@ const passwordField = "password="
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"} var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
// wantRecordedRequestsPass checks that none of the recorded ordinary // wantRecordedRequestsPass checks that none of the recorded ordinary
// requests in the file name in testdata is refused. A request with a body // requests in the file name in testdata is refused, at the defaults and
// or a Content-Type header is checked while bodies are read, and any other // while bodies are read. A request with neither a body nor a Content-Type
// request at the defaults. Reading bodies only adds rule matches, so a // header is checked only at the defaults: with neither, the set that reads
// request that passes while bodies are read passes at the defaults too; // bodies has nothing more to check. Each request runs as its own parallel
// TestReadingBodiesOnlyAddsMatches checks that. Each request runs as its // subtest.
// own parallel subtest.
func wantRecordedRequestsPass(t *testing.T, name string) { func wantRecordedRequestsPass(t *testing.T, name string) {
t.Helper() t.Helper()
@@ -644,21 +643,21 @@ func wantRecordedRequestsPass(t *testing.T, name string) {
r := request{recorded.Method, recorded.Target, recorded.Headers} r := request{recorded.Method, recorded.Target, recorded.Headers}
got, _ := inspectBody(t, defaults, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
}
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool { typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
name, _, _ := strings.Cut(header, ":") name, _, _ := strings.Cut(header, ":")
return strings.EqualFold(name, "Content-Type") return strings.EqualFold(name, "Content-Type")
}) })
if recorded.Body == "" && !typed { if recorded.Body == "" && !typed {
got, _ := inspectBody(t, defaults, r, "")
if got.Score >= threshold {
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
}
return return
} }
got, _ := inspectBody(t, bodies, r, recorded.Body) got, _ = inspectBody(t, bodies, r, recorded.Body)
if got.Score >= threshold { if got.Score >= threshold {
t.Errorf("%s %s %.200q refused while bodies are read: %+v", t.Errorf("%s %s %.200q refused while bodies are read: %+v",
r.method, r.target, recorded.Body, got) r.method, r.target, recorded.Body, got)
@@ -667,78 +666,6 @@ func wantRecordedRequestsPass(t *testing.T, name string) {
} }
} }
// TestReadingBodiesOnlyAddsMatches checks that every rule a recorded
// request with a body matches at the defaults also matches while bodies
// are read, with a score no lower. It checks every 40th such request of
// each app, as recorded and with an SQL injection added to its query, which
// the defaults must match.
func TestReadingBodiesOnlyAddsMatches(t *testing.T) {
t.Parallel()
defaults, bodies := atDefaults(t), readingBodies(t)
for _, name := range []string{
"drupal-requests.txt",
"mastodon-requests.txt",
"mediawiki-requests.txt",
"nextcloud-requests.txt",
"vaultwarden-requests.txt",
"wordpress-requests.txt",
} {
withBody := 0
for _, recorded := range recordedRequests(t, name) {
if recorded.Body == "" {
continue
}
withBody++
if withBody%40 != 1 {
continue
}
separator := "?"
if strings.Contains(recorded.Target, "?") {
separator = "&"
}
r := request{recorded.Method, recorded.Target, recorded.Headers}
wantNoMatchLost(t, defaults, bodies, r, recorded.Body)
r.target += separator + "id=" + sqlInjection
if atDefault := wantNoMatchLost(t, defaults, bodies, r,
recorded.Body); len(atDefault.RuleIDs) == 0 {
t.Errorf("%s %s matched nothing at the defaults", r.method, r.target)
}
}
}
}
// wantNoMatchLost checks that every rule defaults matches in r with body,
// bodies matches too, with a score no lower, and returns what defaults
// found.
func wantNoMatchLost(
t *testing.T, defaults, bodies *coreruleset.CoreRuleSet, r request, body string,
) coreruleset.Result {
t.Helper()
atDefault, _ := inspectBody(t, defaults, r, body)
whileRead, _ := inspectBody(t, bodies, r, body)
for _, id := range atDefault.RuleIDs {
if !slices.Contains(whileRead.RuleIDs, id) {
t.Errorf("%s %s: %d matched at the defaults only", r.method, r.target, id)
}
}
if whileRead.Score < atDefault.Score {
t.Errorf("%s %s scored %d at the defaults and %d while bodies are read",
r.method, r.target, atDefault.Score, whileRead.Score)
}
return atDefault
}
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) { func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -914,28 +841,10 @@ func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
wantRecordedRequestsPass(t, "wordpress-requests.txt")
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t) defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
t.Parallel()
r := request{recorded.Method, recorded.Target, recorded.Headers}
// As in wantRecordedRequestsPass, a request with a body passes at
// the defaults if it passes while bodies are read.
crs := defaults
if recorded.Body != "" {
crs = bodies
}
got, _ := inspectBody(t, crs, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s %.200q refused: %+v", r.method, r.target, recorded.Body, got)
}
})
}
for _, attack := range []string{ for _, attack := range []string{
sqlInjection, sqlInjection,
"..%2F..%2Fwp-config.php", "..%2F..%2Fwp-config.php",