Core Rule Set: random passwords pass at sign-in (closes #276)
check / check (push) Failing after 4m24s
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:
1 parent
463a19ef2a
commit
d8d5d7e54d
12 files changed
+629
-24
No files matched your search
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user