Core Rule Set: random passwords pass at sign-in (closes #276)
check / check (push) Failing after 4m24s

A password manager's random password set off 35 Core Rule Set rules,
about 1 in 40 refused. The password fields the app exclusions name now
skip those rules on their paths: WordPress pwd, Drupal pass, MediaWiki
wpPassword, lgpassword and password, Mastodon's sign-in, sign-up,
password change, move and delete passwords, and Nextcloud's sign-in
password. Each field's test sends 2,000 random passwords from a fixed
seed, plus one rare password for each rule the 2,000 do not set off.
An SQL injection or a path traversal sent as a password now passes; a
script injection is still refused.

Model: opus-5-5
This commit is contained in:
clawbot committed 2026-11-10 09:07:46 +00:00
1 parent 463a19ef2a
commit d8d5d7e54d
12 files changed
+629 -24

No files matched your search

+151
View File
@@ -3,6 +3,7 @@ package coreruleset_test
import (
"encoding/json"
"fmt"
"math/rand/v2"
"net/http"
"net/http/httptest"
"net/netip"
@@ -624,6 +625,145 @@ const passwordField = "password="
//nolint:gochecknoglobals // a constant cannot be a list
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
// randomPassword returns a password of 20 characters, each one of the 95
// printable ASCII characters, space included, drawn from random.
func randomPassword(random *rand.Rand) string {
const length, first, count = 20, ' ', '~' - ' ' + 1
password := make([]byte, length)
for i := range password {
password[i] = byte(first + random.IntN(count))
}
return string(password)
}
// rarePasswords are random passwords from randomPasswords' seed, found among
// the first 1,000,000. Each sets off the rule named beside it, which the first
// 2,000 do not set off.
//
//nolint:gochecknoglobals // a constant cannot be a list
var rarePasswords = []string{
"x)g|E0f)~.um%00G]<oA", // 920270
"uC?mK\\U2fEbXA}~j7fPb", // 920540
"4;yfIDKZYNtZT)(w-=%@", // 921200
"`sAIfEVdBprjxR$/.?\\d", // 930100
"Unt@1xza,:%j$/..;5y+", // 930110
"VN.CVsu^3vbUS==4Q&Y@", // 930120
"gJ<EcT)jhZ'`GC>cX~hM", // 932125
" /\"j14y[%\\(iM]zmG%=^", // 932130
"\\f*q5v$]$IfS3JoY:*r1", // 932160
"*e#!-z{Jq)Sqwx@k:b:}", // 932230
"pErl>vR[Z7K^<f%_4,WZ", // 932260
"){2P,GU}a3mYvjq^(p1^", // 932280
"xw4UGJk!-8#ZE(`.ij.O", // 932330
" XVw(2mN5R,+%~_f\"n<?", // 933100
"0ZK1D>`RHb+RW${/(z}C", // 933135
"Ep3!qqe 7$RT(_)@SLV6", // 933180
"&:4WomDrn$\"(SE_D);Ja", // 933210
"='nlIgN7ON\\\\mWt=Tr84", // 941120
"P]/C>Ef]F-<sV,&gs.[4", // 941160
"<!--a\"9Ju}r!2=J-kZDd", // 941180
"@`].*EJ?`Cw^8\\orD!(f", // 942151
"StTj*t:IF(Uq_<aG0mya", // 942230
"t,D-oJjnvL Dn!o::Int", // 942320
"Z>\"{Z\\QDwqBQs%v}`nBu", // 942550
"`aV#3${J4,\\h.q' k\\${", // 944150
}
// randomPasswords returns 2,000 random passwords from a fixed seed, then
// rarePasswords.
func randomPasswords() []string {
const count, seed = 2000, 276
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
passwords := make([]string, count, count+len(rarePasswords))
for i := range passwords {
passwords[i] = randomPassword(random)
}
return append(passwords, rarePasswords...)
}
// passwordBody returns a body of r's type, form data or JSON, that sends
// each of passwords in the field name.
func passwordBody(t *testing.T, r request, name string, passwords []string) string {
t.Helper()
values := make([]string, len(passwords))
if r.headers[0] != jsonBody {
for i, password := range passwords {
values[i] = url.QueryEscape(name) + "=" + url.QueryEscape(password)
}
return strings.Join(values, "&")
}
for i, password := range passwords {
pair, err := json.Marshal(map[string]string{name: password})
if err != nil {
t.Fatal(err)
}
values[i] = strings.Trim(string(pair), "{}")
}
return "{" + strings.Join(values, ",") + "}"
}
// wantRandomPasswordsPass checks that none of randomPasswords, sent in the
// field name of r's body, is refused while bodies are read. r's Content-Type
// is form data or JSON. Coraza checks each value of a field on its own, and
// keeps every value of a JSON key sent more than once. So each body sends
// the field once for each of 100 passwords, which fit in bodyLimit, and a
// body refused is checked again one password at a time, to name it.
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := readingBodies(t)
for passwords := range slices.Chunk(randomPasswords(), 100) {
body := passwordBody(t, r, name, passwords)
if len(body) > bodyLimit {
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
}
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
continue
}
for _, password := range passwords {
body := passwordBody(t, r, name, []string{password})
if got, _ := inspectBody(t, crs, r, body); got.Score >= threshold {
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
password, got)
}
}
}
}
// wantPasswordAttacksRefused checks that each of attacks, sent as the whole
// password in the field name of r's body, is refused while bodies are read.
func wantPasswordAttacksRefused(
t *testing.T, r request, name string, attacks ...string,
) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range attacks {
body := passwordBody(t, r, name, []string{attack})
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold {
t.Errorf("%s %s %s %q let through: %+v", r.method, r.target, name,
attack, got)
}
}
}
// wantRecordedRequestsPass checks that none of the recorded ordinary
// requests in the file name in testdata is refused, at the defaults and
// while bodies are read. A request with neither a body nor a Content-Type
@@ -1348,6 +1488,17 @@ func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) {
wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la")
}
func TestWordPressPasswordsPass(t *testing.T) {
t.Parallel()
signIn := request{http.MethodPost, wpLogin, []string{formData}}
wantRandomPasswordsPass(t, signIn, "pwd")
// No rule pwd keeps catches the SQL injection or the path traversal.
wantPasswordAttacksRefused(t, signIn, "pwd", scriptTag)
}
func TestWordPressExclusionsAreNarrow(t *testing.T) {
t.Parallel()