diff --git a/README.md b/README.md
index de8e2ed..8bad8ff 100644
--- a/README.md
+++ b/README.md
@@ -602,6 +602,10 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
- Nextcloud: its sync client gets through. On Nextcloud's own paths, its
WebDAV methods, searches, file names, account names, comments and
documents do too.
+- A random password, as a password manager makes one, gets through these forms:
+ - signing in, on WordPress, Drupal, MediaWiki and Nextcloud;
+ - signing in, signing up, changing the password, and moving or deleting the
+ account, on Mastodon.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again
diff --git a/SPEC.md b/SPEC.md
index c3e15ab..5cdaea5 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -919,7 +919,20 @@ The changes hold in front of every app, and no setting undoes them:
- `content` of a reply to a comment sent to `admin-ajax.php`.
- `newcontent` of a plugin's or a theme's file saved from its editor,
which is PHP.
- - `pwd` when signing in, for the list of system files (930120).
+ - `pwd`, the password when signing in, for each rule that random
+ passwords set off:
+
+ 920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130,
+ 932160, 932230, 932250, 932260, 932270, 932280, 932330, 932340,
+ 932370, 932380, 933100, 933135, 933180, 933210, 941100, 941120,
+ 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320,
+ 942540, 942550, 944150.
+
+ `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
+ `
` sent as the password reach WordPress;
+ `` and `javascript:alert(1)` are still
+ refused.
+
- `title`, `excerpt` and `name` in a JSON body sent to any REST API
route under `/wp/v2/`, for the rules on system files and commands
(930120, 932120, 932250, 932260).
@@ -987,6 +1000,18 @@ The changes hold in front of every app, and no setting undoes them:
- The body and comment body on the node and comment forms and through
JSON:API, which hold HTML and any code it shows.
- `name` and `pass` when signing in (930120, 932120, 932250, 932260).
+ - `pass`, the password when signing in, in form data or JSON, for each rule
+ that random passwords set off:
+
+ 920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
+ 932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
+ 933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
+ 942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150.
+
+ `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
+ `
` sent as the password reach Drupal;
+ `` and `javascript:alert(1)` are still refused.
+
- `name` when resetting a password through Drupal's JSON format on
`/user/password`, for the same four rules.
- `mail` in that same reset, for 932120 and 932260 only.
@@ -1066,6 +1091,20 @@ The changes hold in front of every app, and no setting undoes them:
- `wpTextbox1` on MediaWiki's pages;
- `text`, `appendtext` and `prependtext` on `/api.php`;
- `source` in the JSON body of `/rest.php/`.
+ - The passwords when signing in:
+ - `wpPassword` on MediaWiki's pages;
+ - `lgpassword` and `password` on `/api.php`.
+
+ They skip each rule that random passwords set off:
+
+ 920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
+ 932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
+ 933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
+ 942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150.
+
+ `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
+ `
` sent as a password reach MediaWiki;
+ `` and `javascript:alert(1)` are still refused.
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
applies when the query has a parameter of the same name.
@@ -1192,6 +1231,27 @@ The changes hold in front of every app, and no setting undoes them:
Each name contains `.forward`, a file in a home folder. So 930120 refused
every report the web app sent. The keys' values keep every rule.
+ The passwords in a body:
+ - on `/auth`, `/settings/migration` and `/settings/delete`, and the paths
+ under them:
+ - `user[password]`, `user[password_confirmation]` and
+ `user[current_password]`;
+ - `account_migration[current_password]` and
+ `form_redirect[current_password]`;
+ - `form_delete_confirmation[password]`;
+ - `password` on `/api/v1/accounts`, in form data or JSON.
+
+ They skip each rule that random passwords set off:
+
+ 920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
+ 932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
+ 933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
+ 942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150.
+
+ `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
+ `
` sent as a password reach Mastodon;
+ `` and `javascript:alert(1)` are still refused.
+
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
applies when the query has a parameter of the same name.
@@ -1324,6 +1384,19 @@ The changes hold in front of every app, and no setting undoes them:
saves.
- `name` on `/remote.php/dav/systemtags/`, a new tag.
+ `password` in the body of `/login`, the password when signing in, skips each
+ rule that random passwords set off:
+
+ 920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
+ 932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
+ 933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
+ 942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150.
+
+ It applies only when the query has no `password` parameter. `' OR '1'='1`,
+ `../../etc/passwd`, `; cat /etc/passwd` and `
` sent
+ as the password reach Nextcloud; `` and
+ `javascript:alert(1)` are still refused.
+
Some fields hold sentences a person writes:
- `note`, a share's note, on `apps/files_sharing/api/v1/shares` of the OCS
API and on each share under it;
diff --git a/internal/coreruleset/coreruleset_test.go b/internal/coreruleset/coreruleset_test.go
index 6422e12..1c2c7fc 100644
--- a/internal/coreruleset/coreruleset_test.go
+++ b/internal/coreruleset/coreruleset_test.go
@@ -1,8 +1,10 @@
package coreruleset_test
import (
+ "context"
"encoding/json"
"fmt"
+ "math/rand/v2"
"net/http"
"net/http/httptest"
"net/netip"
@@ -624,6 +626,289 @@ 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 = 20
+
+ var printable []byte
+ for c := byte(' '); c <= '~'; c++ {
+ printable = append(printable, c)
+ }
+
+ password := make([]byte, length)
+ for i := range password {
+ password[i] = printable[random.IntN(len(printable))]
+ }
+
+ 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]cX~hM", // 932125
+ " /\"j14y[%\\(iM]zmG%=^", // 932130
+ "\\f*q5v$]$IfS3JoY:*r1", // 932160
+ "*e#!-z{Jq)Sqwx@k:b:}", // 932230
+ "pErl>vR[Z7K^`RHb+RW${/(z}C", // 933135
+ "Ep3!qqe 7$RT(_)@SLV6", // 933180
+ "&:4WomDrn$\"(SE_D);Ja", // 933210
+ "='nlIgN7ON\\\\mWt=Tr84", // 941120
+ "P]/C>Ef]F-\"{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. Coraza checks each value of a field
+// on its own, and keeps every value of a JSON key sent more than once.
+func passwordBody(t *testing.T, r request, name string, passwords ...string) string {
+ t.Helper()
+
+ if r.headers[0] != jsonBody {
+ return formBody(name, passwords...)
+ }
+
+ values := make([]string, len(passwords))
+
+ 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, ",") + "}"
+}
+
+// formBody returns a form data body that sends each of values in the field
+// name.
+func formBody(name string, values ...string) string {
+ fields := make([]string, len(values))
+ for i, value := range values {
+ fields[i] = url.QueryEscape(name) + "=" + url.QueryEscape(value)
+ }
+
+ return strings.Join(fields, "&")
+}
+
+// setsOffARule reports whether body, form data sent to / as a browser
+// sends it, sets off a rule in crs.
+func setsOffARule(crs *coreruleset.CoreRuleSet, body string) bool {
+ req := httptest.NewRequestWithContext(context.Background(), http.MethodPost,
+ "http://git.example/", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Content-Length", strconv.Itoa(len(body)))
+ req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
+ "Gecko/20100101 Firefox/131.0")
+ req.Header.Set("Accept", "text/html")
+
+ // The body is read from memory, which cannot fail.
+ result, _, _ := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
+
+ return len(result.RuleIDs) > 0
+}
+
+// findMisfiringPasswords returns those of randomPasswords that set off a
+// rule in the form data field password on /, which no exclusion names, while
+// bodies are read. It runs once, for every test that asks. Each body sends
+// ten passwords, and a body that sets off a rule is sent again one password
+// at a time.
+//
+//nolint:gochecknoglobals // the tests share it
+var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
+ const workers, perBody = 16, 10
+
+ crs, err := loaderFor(coreruleset.Params{
+ ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
+ })()
+ if err != nil {
+ return nil, err
+ }
+
+ passwords := randomPasswords()
+ misfires := make([]bool, len(passwords))
+
+ var inspecting sync.WaitGroup
+
+ for worker := range workers {
+ inspecting.Go(func() {
+ for first := worker * perBody; first < len(passwords); first += workers * perBody {
+ last := min(first+perBody, len(passwords))
+ if !setsOffARule(crs, formBody("password", passwords[first:last]...)) {
+ continue
+ }
+
+ for i := first; i < last; i++ {
+ misfires[i] = setsOffARule(crs, formBody("password", passwords[i]))
+ }
+ }
+ })
+ }
+
+ inspecting.Wait()
+
+ var misfiring []string
+
+ for i, password := range passwords {
+ if misfires[i] {
+ misfiring = append(misfiring, password)
+ }
+ }
+
+ return misfiring, nil
+})
+
+// misfiringPasswords returns those of randomPasswords that set off a rule in
+// a field no exclusion names. The rules that misfire on a password read its
+// value, not the name of its field. So the other random passwords set off no
+// rule in any field.
+func misfiringPasswords(t *testing.T) []string {
+ t.Helper()
+
+ misfiring, err := findMisfiringPasswords()
+ if err != nil {
+ t.Fatalf("load the Core Rule Set: %v", err)
+ }
+
+ for _, rare := range rarePasswords {
+ if !slices.Contains(misfiring, rare) {
+ t.Fatalf("%q sets off no rule in a field no exclusion names", rare)
+ }
+ }
+
+ return misfiring
+}
+
+// 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. Only the passwords misfiringPasswords returns set off
+// a rule in some field, so only they are sent, all in one body. If it is
+// refused, each is sent again on its own, to name those refused.
+func wantRandomPasswordsPass(t *testing.T, r request, name string) {
+ t.Helper()
+
+ const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
+
+ crs := readingBodies(t)
+ misfiring := misfiringPasswords(t)
+
+ body := passwordBody(t, r, name, misfiring...)
+ 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 {
+ return
+ }
+
+ for _, password := range misfiring {
+ body := passwordBody(t, r, name, 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)
+ }
+ }
+}
+
+// wantPasswordAttacksPass checks that an SQL injection, a path traversal, a
+// shell command and a script in an event handler, each sent as the whole
+// password in the field name of r's body, pass while bodies are read.
+func wantPasswordAttacksPass(t *testing.T, r request, name string) {
+ t.Helper()
+
+ const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
+
+ for _, attack := range []string{
+ "' OR '1'='1", pathTraversal, "; cat /etc/passwd",
+ "
",
+ } {
+ body := passwordBody(t, r, name, attack)
+ if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score >= threshold {
+ t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
+ attack, got)
+ }
+ }
+}
+
+// wantPasswordAttacksRefused checks that a script tag and a javascript: link,
+// each sent as the whole password in the field name of r's body, are refused
+// while bodies are read.
+func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
+ t.Helper()
+
+ const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
+
+ for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
+ body := passwordBody(t, r, name, 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)
+ }
+ }
+}
+
+func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
+ t.Parallel()
+
+ const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
+
+ crs := readingBodies(t)
+
+ // These fields skip the rules only when the query has no field of the
+ // same name. An SQL injection in the query is refused.
+ for _, sent := range []struct{ path, field string }{
+ {nextcloudSignIn, passwordField},
+ {mediawikiAPI, passwordField},
+ {mediawikiAPI, "lgpassword="},
+ } {
+ r := request{
+ http.MethodPost, sent.path + "?" + sent.field + "'%20OR%20'1'='1",
+ []string{formData},
+ }
+
+ got, _ := inspectBody(t, crs, r, sent.field+"example")
+ if got.Score < threshold {
+ t.Errorf("POST %s let through: %+v", r.target, 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 +1633,16 @@ 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")
+ wantPasswordAttacksPass(t, signIn, "pwd")
+ wantPasswordAttacksRefused(t, signIn, "pwd")
+}
+
func TestWordPressExclusionsAreNarrow(t *testing.T) {
t.Parallel()
@@ -1405,6 +1700,9 @@ const injection = "1' OR '1'='1"
// scriptTag is a script injection, not escaped.
const scriptTag = ""
+// pathTraversal is a path traversal, not escaped.
+const pathTraversal = "../../etc/passwd"
+
// readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
diff --git a/internal/coreruleset/drupal.conf b/internal/coreruleset/drupal.conf
index 3ee559c..8c1d5c2 100644
--- a/internal/coreruleset/drupal.conf
+++ b/internal/coreruleset/drupal.conf
@@ -184,6 +184,46 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/"
+# The password, pass, skips each rule that random passwords set off.
+SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
+ "id:900819,phase:1,pass,nolog,chain"
+ SecRule &ARGS_GET:/^(?:json\.)?pass$/ "@eq 0" \
+ "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?pass$/,\
+ ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?pass$/"
+
# A person's name and email address, when signing up, when editing an
# account and when commenting without an account. A name of python3 refused
# signing up, and python3@example.com refused saving an account. An address
diff --git a/internal/coreruleset/drupal_test.go b/internal/coreruleset/drupal_test.go
index 35aaa7e..621ae7a 100644
--- a/internal/coreruleset/drupal_test.go
+++ b/internal/coreruleset/drupal_test.go
@@ -109,7 +109,6 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
{reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack},
{reply, "subject%5B0%5D%5Bvalue%5D=" + attack},
{reply, drupalName + attack},
- {drupalSignIn, "pass=" + attack},
{drupalSignIn, drupalName + attack},
{"/fr/user/login", drupalName + attack},
{"/user/register", drupalName + attack},
@@ -172,6 +171,23 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
}
}
+func TestDrupalPasswordPasses(t *testing.T) {
+ t.Parallel()
+
+ for _, r := range []request{
+ {http.MethodPost, drupalSignIn, []string{formData}},
+ {http.MethodPost, drupalJSONSignIn, []string{jsonBody}},
+ } {
+ t.Run(r.target, func(t *testing.T) {
+ t.Parallel()
+
+ wantRandomPasswordsPass(t, r, "pass")
+ wantPasswordAttacksPass(t, r, "pass")
+ wantPasswordAttacksRefused(t, r, "pass")
+ })
+ }
+}
+
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
t.Parallel()
diff --git a/internal/coreruleset/mastodon.conf b/internal/coreruleset/mastodon.conf
index 4b3d16f..0b09f71 100644
--- a/internal/coreruleset/mastodon.conf
+++ b/internal/coreruleset/mastodon.conf
@@ -176,6 +176,85 @@ SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/"
+# The passwords named above skip each rule that random passwords set off.
+SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete)|auth)(?:/|$)" \
+ "id:900965,phase:1,pass,nolog,chain"
+ SecRule &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \
+ "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
+ ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/"
+
+SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
+ "id:900966,phase:1,pass,nolog,chain"
+ SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \
+ "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\
+ ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/"
+
# The web app's reports have the JSON keys forward and forward_to_domains. A
# report forwarded to another server lists that server in forward_to_domains.
# Their names contain .forward, a file in a home folder, so 930120 refused every
diff --git a/internal/coreruleset/mastodon_test.go b/internal/coreruleset/mastodon_test.go
index 4cf28b9..3c1d225 100644
--- a/internal/coreruleset/mastodon_test.go
+++ b/internal/coreruleset/mastodon_test.go
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"slices"
+ "strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -129,10 +130,6 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, "/settings/applications", "doorkeeper_application[website]="},
{http.MethodPost, "/settings/aliases", "account_alias[acct]="},
{http.MethodPost, "/settings/migration", "account_migration[acct]="},
- {http.MethodPost, mastodonSignIn, mastodonPassword},
- {http.MethodPost, mastodonAccount, mastodonPassword},
- {http.MethodPost, mastodonAccount, "user[password_confirmation]="},
- {http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="},
{http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="},
{http.MethodPost, "/admin/report_notes", "report_note[content]="},
@@ -145,6 +142,19 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, mastodonRoles, "user_role[name]="},
{http.MethodPost, mastodonTerms, "terms_of_service[text]="},
{http.MethodPost, mastodonTerms, "terms_of_service[changelog]="},
+ {http.MethodPost, mastodonSignUp, "reason="},
+}
+
+// mastodonPasswordForms sends each password field in a form data body, on
+// a path where it skips the four rules and the rules that random passwords
+// set off.
+//
+//nolint:gochecknoglobals // a constant cannot be a list
+var mastodonPasswordForms = []mastodonBodyField{
+ {http.MethodPost, mastodonSignIn, mastodonPassword},
+ {http.MethodPost, mastodonAccount, mastodonPassword},
+ {http.MethodPost, mastodonAccount, "user[password_confirmation]="},
+ {http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, "/settings/migration", "account_migration[current_password]="},
{
http.MethodPost, "/settings/migration/redirect",
@@ -152,7 +162,14 @@ var mastodonFormFields = []mastodonBodyField{
},
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
{http.MethodPost, mastodonSignUp, passwordField},
- {http.MethodPost, mastodonSignUp, "reason="},
+}
+
+// mastodonPasswordJSON sends the password of signing up through the API in
+// a JSON body, with %s for its value.
+//
+//nolint:gochecknoglobals // a struct cannot be a constant
+var mastodonPasswordJSON = mastodonBodyField{
+ http.MethodPost, mastodonSignUp, `{"password":%s}`,
}
// mastodonJSONFields sends each field that skips the four rules in a JSON
@@ -178,7 +195,6 @@ var mastodonJSONFields = []mastodonBodyField{
{http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`},
{http.MethodPost, mastodonReports, `{"comment":%s}`},
{http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`},
- {http.MethodPost, mastodonSignUp, `{"password":%s}`},
{http.MethodPost, mastodonSignUp, `{"reason":%s}`},
{http.MethodPost, "/inbox", `{"object":{"content":%s}}`},
{http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`},
@@ -287,6 +303,34 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
}
}
+func TestMastodonPasswordsPass(t *testing.T) {
+ t.Parallel()
+
+ for _, sent := range mastodonPasswordForms {
+ t.Run(sent.target+" "+sent.field, func(t *testing.T) {
+ t.Parallel()
+
+ r := request{sent.method, sent.target, []string{formData}}
+ name := strings.TrimSuffix(sent.field, "=")
+
+ wantRandomPasswordsPass(t, r, name)
+ wantPasswordAttacksPass(t, r, name)
+ wantPasswordAttacksRefused(t, r, name)
+ })
+ }
+
+ t.Run("JSON", func(t *testing.T) {
+ t.Parallel()
+
+ r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target,
+ []string{jsonBody}}
+
+ wantRandomPasswordsPass(t, r, "password")
+ wantPasswordAttacksPass(t, r, "password")
+ wantPasswordAttacksRefused(t, r, "password")
+ })
+}
+
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Parallel()
@@ -299,7 +343,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
}
}
- for _, sent := range mastodonFormFields {
+ for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) {
if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
t.Errorf("%v let through", sent)
}
@@ -310,7 +354,8 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Fatal(err)
}
- for _, sent := range mastodonJSONFields {
+ passwordJSON := []mastodonBodyField{mastodonPasswordJSON}
+ for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) {
if mastodonJSON(t, sent, attack) < mastodonThreshold {
t.Errorf("%v let through", sent)
}
@@ -330,6 +375,12 @@ func TestMastodonHardValuesPassInTheExcludedFields(t *testing.T) {
t.Errorf("%s refused: %d", sent, score)
}
}
+
+ for _, sent := range mastodonPasswordForms {
+ if mastodonForm(t, sent, value) >= mastodonThreshold {
+ t.Errorf("%v with %s refused", sent, value)
+ }
+ }
}
code := url.QueryEscape(mastodonCodePost)
diff --git a/internal/coreruleset/mediawiki.conf b/internal/coreruleset/mediawiki.conf
index 590a63a..7fbf9ae 100644
--- a/internal/coreruleset/mediawiki.conf
+++ b/internal/coreruleset/mediawiki.conf
@@ -108,6 +108,86 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/"
+# wpPassword, lgpassword and password skip each rule random passwords set off.
+SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
+ "id:900917,phase:1,pass,nolog,chain"
+ SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
+ SecRule &ARGS_GET:wpPassword "@eq 0" \
+ "ctl:ruleRemoveTargetById=920270;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=920540;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=921200;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=930100;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=930110;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=930120;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932125;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932130;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932160;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932230;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932250;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932260;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932270;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932280;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932330;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932340;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932370;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=932380;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=933100;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=933135;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=933180;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=933210;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=941100;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=941120;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=941160;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=941180;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=942100;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=942151;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=942190;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=942230;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=942290;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=942320;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=942540;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=942550;ARGS:wpPassword,\
+ ctl:ruleRemoveTargetById=944150;ARGS:wpPassword"
+
+SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
+ "id:900918,phase:1,pass,nolog,chain"
+ SecRule &ARGS_GET:/^(?:lgpassword|password)$/ "@eq 0" \
+ "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password)$/,\
+ ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password)$/"
+
# Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext
# or prependtext, and the REST API in the JSON field source. Each of these
# rules refused saving or previewing a page whose wikitext held a template, a
diff --git a/internal/coreruleset/mediawiki_test.go b/internal/coreruleset/mediawiki_test.go
index 66a8137..4a551d3 100644
--- a/internal/coreruleset/mediawiki_test.go
+++ b/internal/coreruleset/mediawiki_test.go
@@ -3,6 +3,7 @@ package coreruleset_test
import (
"net/http"
"slices"
+ "strings"
"testing"
)
@@ -49,9 +50,9 @@ func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
}
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
-// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
-// the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
-// path where it skips them, ending in "=".
+// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
+// the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends
+// each in the query, on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var mediawikiQueryFields = []string{
@@ -136,9 +137,9 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
type mediawikiBodyField struct{ target, field string }
// mediawikiBodyFields and mediawikiQueryFields together hold every field that
-// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
-// the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
-// a path where it skips them.
+// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
+// the passwords and the JSON fields of /rest.php/. mediawikiBodyFields sends
+// each in a form data body, on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mediawikiBodyFields = []mediawikiBodyField{
@@ -146,13 +147,11 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiUpload, "wpUploadDescription="},
{mediawikiUpload, "wpDestFile="},
{mediawikiSignIn, "wpName="},
- {mediawikiSignIn, "wpPassword="},
{mediawikiMove, "wpOldTitle="},
{mediawikiMove, "wpNewTitleMain="},
{mediawikiMove, "wpReason="},
{mediawikiBlock, "wpTarget="},
{mediawikiSignInPage, "wpName="},
- {"/w" + mediawikiSignInPage, "wpPassword="},
{mediawikiUploadPage, "wpDestFile="},
{"/w" + mediawikiUploadPage, "wpUploadDescription="},
{mediawikiAPI, "title="},
@@ -165,8 +164,18 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiAPI, "reason="},
{mediawikiAPI, "user="},
{mediawikiAPI, "lgname="},
- {mediawikiAPI, "lgpassword="},
{mediawikiAPI, "username="},
+}
+
+// mediawikiPasswordFields are the passwords, which skip the four rules and the
+// rules that random passwords set off. mediawikiPasswordFields sends each in a
+// form data body, on a path where it skips them.
+//
+//nolint:gochecknoglobals // a constant cannot be a list
+var mediawikiPasswordFields = []mediawikiBodyField{
+ {mediawikiSignIn, "wpPassword="},
+ {"/w" + mediawikiSignInPage, "wpPassword="},
+ {mediawikiAPI, "lgpassword="},
{mediawikiAPI, passwordField},
}
@@ -233,6 +242,23 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
}
}
+func TestMediaWikiPasswordsPass(t *testing.T) {
+ t.Parallel()
+
+ for _, sent := range mediawikiPasswordFields {
+ t.Run(sent.target+" "+sent.field, func(t *testing.T) {
+ t.Parallel()
+
+ r := request{http.MethodPost, sent.target, []string{formData}}
+ name := strings.TrimSuffix(sent.field, "=")
+
+ wantRandomPasswordsPass(t, r, name)
+ wantPasswordAttacksPass(t, r, name)
+ wantPasswordAttacksRefused(t, r, name)
+ })
+ }
+}
+
// mediawikiCommandAttack is a shell command that only 932235 refuses on the
// fields that skip the other command rules, as a query value.
const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
@@ -253,7 +279,8 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
crs = readingBodies(t)
- for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields) {
+ for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields,
+ mediawikiPasswordFields) {
r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack)
@@ -311,8 +338,8 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
}
}
- fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...),
- mediawikiWikitextFields...)
+ fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
+ mediawikiPasswordFields)
for _, sent := range fields {
r := request{http.MethodPost, sent.target, []string{formData}}
diff --git a/internal/coreruleset/nextcloud.conf b/internal/coreruleset/nextcloud.conf
index 43f3f67..96f675f 100644
--- a/internal/coreruleset/nextcloud.conf
+++ b/internal/coreruleset/nextcloud.conf
@@ -61,6 +61,46 @@ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/"
+# The password, in the body, skips each rule that random passwords set off.
+SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
+ "id:900125,phase:1,pass,nolog,chain"
+ SecRule &ARGS_GET:password "@eq 0" \
+ "ctl:ruleRemoveTargetById=920270;ARGS:password,\
+ ctl:ruleRemoveTargetById=920540;ARGS:password,\
+ ctl:ruleRemoveTargetById=921200;ARGS:password,\
+ ctl:ruleRemoveTargetById=930100;ARGS:password,\
+ ctl:ruleRemoveTargetById=930110;ARGS:password,\
+ ctl:ruleRemoveTargetById=930120;ARGS:password,\
+ ctl:ruleRemoveTargetById=932125;ARGS:password,\
+ ctl:ruleRemoveTargetById=932130;ARGS:password,\
+ ctl:ruleRemoveTargetById=932160;ARGS:password,\
+ ctl:ruleRemoveTargetById=932230;ARGS:password,\
+ ctl:ruleRemoveTargetById=932250;ARGS:password,\
+ ctl:ruleRemoveTargetById=932260;ARGS:password,\
+ ctl:ruleRemoveTargetById=932270;ARGS:password,\
+ ctl:ruleRemoveTargetById=932280;ARGS:password,\
+ ctl:ruleRemoveTargetById=932330;ARGS:password,\
+ ctl:ruleRemoveTargetById=932340;ARGS:password,\
+ ctl:ruleRemoveTargetById=932370;ARGS:password,\
+ ctl:ruleRemoveTargetById=932380;ARGS:password,\
+ ctl:ruleRemoveTargetById=933100;ARGS:password,\
+ ctl:ruleRemoveTargetById=933135;ARGS:password,\
+ ctl:ruleRemoveTargetById=933180;ARGS:password,\
+ ctl:ruleRemoveTargetById=933210;ARGS:password,\
+ ctl:ruleRemoveTargetById=941100;ARGS:password,\
+ ctl:ruleRemoveTargetById=941120;ARGS:password,\
+ ctl:ruleRemoveTargetById=941160;ARGS:password,\
+ ctl:ruleRemoveTargetById=941180;ARGS:password,\
+ ctl:ruleRemoveTargetById=942100;ARGS:password,\
+ ctl:ruleRemoveTargetById=942151;ARGS:password,\
+ ctl:ruleRemoveTargetById=942190;ARGS:password,\
+ ctl:ruleRemoveTargetById=942230;ARGS:password,\
+ ctl:ruleRemoveTargetById=942290;ARGS:password,\
+ ctl:ruleRemoveTargetById=942320;ARGS:password,\
+ ctl:ruleRemoveTargetById=942540;ARGS:password,\
+ ctl:ruleRemoveTargetById=942550;ARGS:password,\
+ ctl:ruleRemoveTargetById=944150;ARGS:password"
+
# The folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
"id:900103,phase:1,pass,nolog,\
diff --git a/internal/coreruleset/nextcloud_test.go b/internal/coreruleset/nextcloud_test.go
index c54ab95..d8190ec 100644
--- a/internal/coreruleset/nextcloud_test.go
+++ b/internal/coreruleset/nextcloud_test.go
@@ -16,6 +16,9 @@ import (
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5
+// nextcloudSignIn is the sign-in page.
+const nextcloudSignIn = "/login"
+
// nextcloudShare is a public share's page.
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
@@ -83,8 +86,7 @@ var nextcloudQueryFields = []string{
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudFormFields = []struct{ target, field string }{
- {"/login", "user="},
- {"/login", passwordField},
+ {nextcloudSignIn, "user="},
{nextcloudUsers, "userid="},
{nextcloudUsers, "displayName="},
{nextcloudUsers, "groups%5B%5D="},
@@ -301,6 +303,17 @@ func nextcloudTextScores(t *testing.T, text string) map[string]int {
return scores
}
+// nextcloudPasswordScore returns the score that the Core Rule Set, reading
+// bodies, gives signing in with value, a query value, as the password.
+func nextcloudPasswordScore(t *testing.T, value string) int {
+ t.Helper()
+
+ r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
+ got, _ := inspectBody(t, readingBodies(t), r, passwordField+value)
+
+ return got.Score
+}
+
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Parallel()
@@ -310,9 +323,23 @@ func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Errorf("%s refused: %d", request, score)
}
}
+
+ if score := nextcloudPasswordScore(t, value); score >= nextcloudThreshold {
+ t.Errorf("password %s refused: %d", value, score)
+ }
}
}
+func TestNextcloudPasswordPasses(t *testing.T) {
+ t.Parallel()
+
+ r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
+
+ wantRandomPasswordsPass(t, r, "password")
+ wantPasswordAttacksPass(t, r, "password")
+ wantPasswordAttacksRefused(t, r, "password")
+}
+
func TestNextcloudSentencesPassInTheTexts(t *testing.T) {
t.Parallel()
@@ -361,6 +388,10 @@ func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
t.Errorf("%s let through: %d", request, score)
}
}
+
+ if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold {
+ t.Errorf("password %s let through: %d", commandInjection, score)
+ }
}
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
diff --git a/internal/coreruleset/wordpress.conf b/internal/coreruleset/wordpress.conf
index da98bab..7378d5d 100644
--- a/internal/coreruleset/wordpress.conf
+++ b/internal/coreruleset/wordpress.conf
@@ -96,11 +96,45 @@ SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\
ctl:ruleRemoveTargetById=942540;ARGS:newcontent"
-# Signing in, in pwd, where a password holds any characters:
-# 930120: a password ending in /etc/hosts
+# Signing in, in pwd, which skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@streq /wp-login.php" \
"id:900704,phase:1,pass,nolog,chain"
- SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd"
+ SecRule &ARGS_GET:pwd "@eq 0" \
+ "ctl:ruleRemoveTargetById=920270;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=920540;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=921200;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=930100;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=930110;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=930120;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932125;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932130;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932160;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932230;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932250;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932260;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932270;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932280;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932330;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932340;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932370;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=932380;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=933100;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=933135;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=933180;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=933210;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=941100;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=941120;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=941160;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=941180;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=942100;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=942151;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=942190;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=942230;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=942290;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=942320;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=942540;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=942550;ARGS:pwd,\
+ ctl:ruleRemoveTargetById=944150;ARGS:pwd"
# Saving through the REST API, the JSON fields title, excerpt and name skip these
# rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only