From 3e7837aa41f40bd542651555e1b624fc98dd537f Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 10 Nov 2026 15:48:18 +0000 Subject: [PATCH] Core Rule Set: random passwords pass on the remaining password fields (closes #288) Each app's sign-in, sign-up, password change and reset fields that had no exclusion now skip the 35 rules random passwords set off, on that app's paths and only when the query has no field of that name. Added gitea, Vaultwarden's admin token, WordPress pass1/pass2/post_password and its REST password, Drupal's forms and JSON, MediaWiki retype and password on the pages that take them, Mastodon's challenge, and Nextcloud's confirm, reset, change, share, LDAP renewal, encryption and OCS fields. Vaultwarden's clients send a hash, which needs no exclusion. Model: opus-5-5 --- README.md | 9 +- SPEC.md | 104 ++++++++++++- internal/coreruleset/coreruleset_test.go | 190 ++++++++++++++++++++--- internal/coreruleset/drupal.conf | 46 ++++++ internal/coreruleset/drupal_test.go | 36 ++++- internal/coreruleset/gitea.conf | 47 ++++++ internal/coreruleset/mastodon.conf | 78 +++++----- internal/coreruleset/mastodon_test.go | 12 ++ internal/coreruleset/mediawiki.conf | 168 +++++++++++++++----- internal/coreruleset/mediawiki_test.go | 60 +++++++ internal/coreruleset/nextcloud.conf | 95 ++++++++++++ internal/coreruleset/nextcloud_test.go | 105 +++++++++++++ internal/coreruleset/vaultwarden.conf | 42 +++++ internal/coreruleset/vaultwarden_test.go | 69 ++++++++ internal/coreruleset/wordpress.conf | 86 ++++++++++ 15 files changed, 1043 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 089a71d..7f8d919 100644 --- a/README.md +++ b/README.md @@ -603,9 +603,12 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md). 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. + - signing in, signing up, and changing or resetting a password, on + WordPress, Drupal, MediaWiki, Mastodon, Nextcloud and gitea; + - moving or deleting the account, on Mastodon; + - deleting the account, on gitea; + - the admin page, on Vaultwarden, whose apps send a hash of the master + password, not the password itself. - `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 5cdaea5..c71f107 100644 --- a/SPEC.md +++ b/SPEC.md @@ -858,6 +858,27 @@ The changes hold in front of every app, and no setting undoes them: - to list a directory's last commits. - The `Content-Range` header of an artifact upload from `actions/upload-artifact@v3` (920450). + - The passwords in a body, for each rule that random passwords set off. The + numbers are in `internal/coreruleset/gitea.conf`. + - `password` on these paths: + - `/user/login`, `/user/sign_up`, `/user/activate`; + - `/user/link_account_signin` and `/user/link_account_signup`; + - `/user/recover_account`, which resets a password; + - `/user/settings/change_password` and `/user/settings/account`; + - `/user/settings/account/delete`; + - `/-/admin/users/new` and `/-/admin/users//edit`, and the same + paths under `/admin/`. + - `retype` on `/user/sign_up`, `/user/link_account_signup`, + `/user/settings/change_password` and `/user/settings/account`. + - `old_password` on `/user/settings/account`. + - `password` in the JSON body of `/api/v1/admin/users` and each user + under it. + + Each field listed skips these rules on every path listed, not only on + the paths given for it. None applies when the query has a parameter of + the same name. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` + and `` sent as the password reach gitea; + `` and `javascript:alert(1)` are still refused. Elsewhere these fields keep every rule. The paths are those of gitea at the site root. An app other than gitea that serves one of them must check these @@ -933,6 +954,20 @@ The changes hold in front of every app, and no setting undoes them: `` and `javascript:alert(1)` are still refused. + - `pass1`, `pass2` and `post_password` on `/wp-login.php`, which reset a + password and sign in to a protected post, for the same rules. + - `pass1` and `pass2` on these pages, for the same rules: + - `/wp-admin/profile.php`; + - `/wp-admin/user-edit.php`; + - `/wp-admin/user-new.php`; + - `/wp-admin/network/user-edit.php` and + `/wp-admin/user/profile.php`, on a multisite install. + + Each of `pass1`, `pass2` and `post_password` skips these rules on + all of these pages and on `/wp-login.php`. + + - `password` in form data or JSON on the REST API's `/wp/v2/users` and + the user under it, for the same rules. - `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). @@ -1012,6 +1047,17 @@ The changes hold in front of every app, and no setting undoes them: `` sent as the password reach Drupal; `` and `javascript:alert(1)` are still refused. + - The other passwords, for the same rules: + - `pass[pass1]`, `pass[pass2]` and `current_pass` on `/user/register`, + `/user//edit` and `/admin/people/create`; + - `pass.value`, `pass.0.value` and `pass.0.existing` in the JSON body of + `/user/register` and `/user/`; + - `data.attributes.pass.value` and `data.attributes.pass.existing` in + the JSON:API body of `/jsonapi/user/user` and each user under it. + + Each field listed skips these rules on every path listed, not only on + the paths given for it. + - `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. @@ -1091,9 +1137,13 @@ 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: + - The passwords when signing in, signing up and changing a password: - `wpPassword` on MediaWiki's pages; - - `lgpassword` and `password` on `/api.php`. + - `password` and `retype` on `Special:ChangeCredentials`; + - `retype` on `Special:CreateAccount`; + - `retype` on `Special:UserLogin`, where a password reset sets the new + password; + - `lgpassword`, `password` and `retype` on `/api.php`. They skip each rule that random passwords set off: @@ -1106,6 +1156,10 @@ The changes hold in front of every app, and no setting undoes them: `` sent as a password reach MediaWiki; `` and `javascript:alert(1)` are still refused. + The path must name the special page, or be `/index.php` with a `title` + that names it. Other apps use `password` and `retype` too, so on any + other page those two fields keep every rule. + These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None applies when the query has a parameter of the same name. @@ -1239,6 +1293,9 @@ The changes hold in front of every app, and no setting undoes them: - `account_migration[current_password]` and `form_redirect[current_password]`; - `form_delete_confirmation[password]`; + - `form_challenge[current_password]`, which asks for the password again, on + `/auth/challenge`, `/settings/two_factor_authentication` and + `/settings/two_factor_authentication_methods`, and the paths under them; - `password` on `/api/v1/accounts`, in form data or JSON. They skip each rule that random passwords set off: @@ -1248,6 +1305,9 @@ The changes hold in front of every app, and no setting undoes them: 933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150. + Each field in the first two items skips these rules on every path in those + items. + `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and `` sent as a password reach Mastodon; `` and `javascript:alert(1)` are still refused. @@ -1322,6 +1382,17 @@ The changes hold in front of every app, and no setting undoes them: `username` in the query of `/api/hibp/breach`, the data breach report, skips 930120, 932120 and 932260 too. + `token` in the body of `/admin`, the admin token typed on the admin page, + skips each rule that random passwords set off. The numbers are in + `internal/coreruleset/vaultwarden.conf`. It applies only when the query has + no `token` parameter. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` + and `` sent as the token reach Vaultwarden; + `` and `javascript:alert(1)` are still refused. + + The apps hash the master password before sending it. So `password` on + `/identity/connect/token`, `masterPasswordHash` and `newMasterPasswordHash` + hold only base64 and need no exclusion. + The names of uploaded files skip 920120 on the paths that take attachments and file sends: - `/api/ciphers//attachment` and the paths under it; @@ -1397,6 +1468,35 @@ The changes hold in front of every app, and no setting undoes them: as the password reach Nextcloud; `` and `javascript:alert(1)` are still refused. + The other passwords skip the same rules, in form data or JSON. None applies + when the query has a parameter of the same name. + - `password` on `/login/confirm`, `/lostpassword/set//`, + `/settings/users/changepassword` and a public share's + `/s//authenticate/`. + - `oldpassword` and `newpassword` on `/settings/personal/changepassword`. + - `recoveryPassword` on `/settings/users/changepassword`. + - `oldPassword` and `newPassword` on `/apps/user_ldap/renewpassword`, which + changes an expired password, and on + `/apps/encryption/ajax/updatePrivateKeyPassword`. + - `oldPassword`, `newPassword` and `confirmPassword` on + `/apps/encryption/ajax/changeRecoveryPassword`. + - `recoveryPassword` and `confirmPassword` on + `/apps/encryption/ajax/adminRecovery`. + - `password` on the OCS API's `person/check`, which checks a sign-in, and + `core/apppassword/confirm`, which confirms the password. + - `password` on the OCS API's `cloud/users` and the share API's + `apps/files_sharing/api/v1/shares`, each user or share under them, and a + share's `send-email`. + - `value` on the OCS API's `cloud/users/` when the body's `key` is + `password`. A body with more than one `key`, or a `key` or `value` in the + query, gets none of this. + + Each field listed but `value` skips these rules on every path listed, not + only on the paths given for it. + + `password` on `/login/flow/apptoken` and `/login/v2/apptoken` holds a + generated app token, so it needs no exclusion. + 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 d8a6066..133e943 100644 --- a/internal/coreruleset/coreruleset_test.go +++ b/internal/coreruleset/coreruleset_test.go @@ -588,6 +588,12 @@ const ( // several apps take a password. const passwordField = "password=" +// passwordKey is that field's name, without the "=". +const passwordKey = "password" + +// retypeKey is the field in which apps take the same password again. +const retypeKey = "retype" + // hardValues are words that name commands and files, as a query or form // value. An app's tests check that its fields let them through. // @@ -661,27 +667,45 @@ func randomPasswords() []string { } // 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 { +// each of passwords in each of names. In JSON, a dot in a name nests the +// field: pass.0.value is {"pass":{"0":{"value":password}}}, which Coraza +// names as it names the same field sent in an array. 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, names []string, passwords ...string) string { t.Helper() - if r.headers[0] != jsonBody { - return formBody(name, passwords...) - } + var fields []string - values := make([]string, len(passwords)) + for _, name := range names { + if r.headers[0] != jsonBody { + fields = append(fields, formBody(name, passwords...)) - for i, password := range passwords { - pair, err := json.Marshal(map[string]string{name: password}) - if err != nil { - t.Fatal(err) + continue } - values[i] = strings.Trim(string(pair), "{}") + keys := strings.Split(name, ".") + + for _, password := range passwords { + var value any = password + for i := len(keys) - 1; i > 0; i-- { + value = map[string]any{keys[i]: value} + } + + pair, err := json.Marshal(map[string]any{keys[0]: value}) + if err != nil { + t.Fatal(err) + } + + fields = append(fields, string(pair[1:len(pair)-1])) + } } - return "{" + strings.Join(values, ",") + "}" + if r.headers[0] != jsonBody { + return strings.Join(fields, "&") + } + + return "{" + strings.Join(fields, ",") + "}" } // formBody returns a form data body that sends each of values in the field @@ -795,8 +819,9 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) { crs := readingBodies(t) misfiring := misfiringPasswords(t) + names := []string{name} - body := passwordBody(t, r, name, misfiring...) + body := passwordBody(t, r, names, misfiring...) if len(body) > bodyLimit { t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body)) } @@ -806,7 +831,7 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) { } for _, password := range misfiring { - body := passwordBody(t, r, name, password) + body := passwordBody(t, r, names, 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) @@ -816,8 +841,9 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) { // 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) { +// password in each of the fields names of r's body, pass while bodies are +// read. +func wantPasswordAttacksPass(t *testing.T, r request, names ...string) { t.Helper() const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default @@ -826,9 +852,9 @@ func wantPasswordAttacksPass(t *testing.T, r request, name string) { "' OR '1'='1", pathTraversal, "; cat /etc/passwd", "", } { - body := passwordBody(t, r, name, attack) + body := passwordBody(t, r, names, 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, + t.Errorf("%s %s %v %q refused: %+v", r.method, r.target, names, attack, got) } } @@ -843,7 +869,7 @@ func wantPasswordAttacksRefused(t *testing.T, r request, name string) { const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default for _, attack := range []string{scriptTag, "javascript:alert(1)"} { - body := passwordBody(t, r, name, attack) + body := passwordBody(t, r, []string{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) @@ -851,6 +877,64 @@ func wantPasswordAttacksRefused(t *testing.T, r request, name string) { } } +// passwordFields names the password fields one form or JSON body sends to +// target. contentType is formData or jsonBody. +type passwordFields struct { + target, contentType string + names []string +} + +// wantPasswordFieldsPass checks that the random passwords pass in each field +// of fields, that the attacks that pass in a password pass in all of a +// target's fields sent together, and that a script tag or a javascript: link +// in any one field is still refused. Sending the attacks together keeps the +// test cheap: a body of them is refused if any one field checks them. +func wantPasswordFieldsPass(t *testing.T, fields []passwordFields) { + t.Helper() + + for _, sent := range fields { + t.Run(sent.target+" "+sent.contentType, func(t *testing.T) { + t.Parallel() + + r := request{http.MethodPost, sent.target, []string{sent.contentType}} + + wantPasswordAttacksPass(t, r, sent.names...) + + for _, name := range sent.names { + wantRandomPasswordsPass(t, r, name) + wantPasswordAttacksRefused(t, r, name) + } + }) + } +} + +func TestGiteaPasswordsPass(t *testing.T) { + t.Parallel() + + // gitea's password fields, each sent to one of the paths that takes it. + fields := []passwordFields{ + {"/user/login", formData, []string{passwordKey}}, + {"/user/sign_up", formData, []string{passwordKey, retypeKey}}, + {"/user/link_account_signin", formData, []string{passwordKey}}, + {"/user/link_account_signup", formData, []string{passwordKey, retypeKey}}, + {"/user/activate", formData, []string{passwordKey}}, + {"/user/recover_account", formData, []string{passwordKey}}, + {"/user/settings/change_password", formData, []string{passwordKey, retypeKey}}, + { + "/user/settings/account", formData, + []string{"old_password", passwordKey, retypeKey}, + }, + {"/user/settings/account/delete", formData, []string{passwordKey}}, + {"/-/admin/users/new", formData, []string{passwordKey}}, + {"/-/admin/users/1/edit", formData, []string{passwordKey}}, + {"/admin/users/1/edit", formData, []string{passwordKey}}, + {"/api/v1/admin/users", jsonBody, []string{passwordKey}}, + {"/api/v1/admin/users/alice", jsonBody, []string{passwordKey}}, + } + + wantPasswordFieldsPass(t, fields) +} + func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) { t.Parallel() @@ -864,6 +948,24 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) { {nextcloudSignIn, passwordField}, {mediawikiAPI, passwordField}, {mediawikiAPI, "lgpassword="}, + {mediawikiAPI, retypeKey + "="}, + {mediawikiAccountChange, passwordField}, + {mediawikiAccountChange, retypeKey + "="}, + {mediawikiSignUp, retypeKey + "="}, + {"/wiki/Special:UserLogin", retypeKey + "="}, + {"/user/sign_up", passwordField}, + {"/user/settings/account", "old_password="}, + {wpLogin, "pass1="}, + {wpLogin, "post_password="}, + {"/wp-admin/profile.php", "pass2="}, + {"/wp-json/wp/v2/users", passwordField}, + {drupalRegister, "pass%5Bpass1%5D="}, + {"/user/1/edit", "current_pass="}, + {"/auth/challenge", "form_challenge%5Bcurrent_password%5D="}, + {"/login/confirm", passwordField}, + {"/settings/personal/changepassword", "newpassword="}, + {"/apps/encryption/ajax/adminRecovery", "confirmPassword="}, + {"/admin", "token="}, } { r := request{ http.MethodPost, sent.path + "?" + sent.field + "'%20OR%20'1'='1", @@ -877,6 +979,35 @@ func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) { } } +// The password exclusions apply in front of every app. So each field keeps +// every rule on a nearby path that its exclusion does not name. +func TestPasswordExclusionsAreNarrow(t *testing.T) { + t.Parallel() + + const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default + + crs := readingBodies(t) + + for _, sent := range []struct{ target, contentType, name string }{ + {"/user/settings/profile", formData, passwordKey}, + {"/api/v1/user/repos", jsonBody, passwordKey}, + {"/admin/users/overview", formData, "token"}, + {"/wp-admin/options.php", formData, "pass1"}, + {"/wp-admin/post.php", formData, "post_password"}, + {"/user/password", formData, "pass[pass1]"}, + {"/settings/apps", formData, passwordKey}, + {"/ocs/v2.php/apps/spreed/api/v4/room", jsonBody, passwordKey}, + {"/settings/profile", formData, "form_challenge[current_password]"}, + } { + r := request{http.MethodPost, sent.target, []string{sent.contentType}} + + body := passwordBody(t, r, []string{sent.name}, "' OR '1'='1") + if got, _ := inspectBody(t, crs, r, body); got.Score < threshold { + t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got) + } + } +} + func TestGiteaAttacksAreRefused(t *testing.T) { t.Parallel() @@ -1503,6 +1634,25 @@ func TestWordPressPasswordsPass(t *testing.T) { wantPasswordAttacksRefused(t, signIn, "pwd") } +func TestWordPressPasswordFieldsPass(t *testing.T) { + t.Parallel() + + pass := []string{"pass1", "pass2"} + + wantPasswordFieldsPass(t, []passwordFields{ + {wpLogin + "?action=resetpass", formData, pass}, + {wpLogin + "?action=postpass", formData, []string{"post_password"}}, + {"/wp-admin/profile.php", formData, pass}, + {"/wp-admin/user-edit.php?user_id=2", formData, pass}, + {"/wp-admin/user-new.php", formData, pass}, + {"/wp-admin/network/user-edit.php?user_id=2", formData, pass}, + {"/wp-admin/user/profile.php", formData, pass}, + {"/wp-json/wp/v2/users", jsonBody, []string{passwordKey}}, + {"/wp-json/wp/v2/users/me", formData, []string{passwordKey}}, + {"/index.php?rest_route=/wp/v2/users/2", jsonBody, []string{passwordKey}}, + }) +} + func TestWordPressExclusionsAreNarrow(t *testing.T) { t.Parallel() diff --git a/internal/coreruleset/drupal.conf b/internal/coreruleset/drupal.conf index 8c1d5c2..305b2f4 100644 --- a/internal/coreruleset/drupal.conf +++ b/internal/coreruleset/drupal.conf @@ -224,6 +224,52 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \ ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?pass$/,\ ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?pass$/" +# The passwords when signing up, changing the password and adding a user skip +# the same rules: +# pass[pass1], pass[pass2], current_pass: the forms at /user/register, +# /user//edit and /admin/people/create +# json.pass.value, json.pass.0.value, json.pass.0.existing: a client signing up +# or changing the password in Drupal's JSON format +# json.data.attributes.pass.value, .existing: a JSON:API client doing the same +SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/(?:user/register|user/[0-9]+(?:/edit)?|admin/people/create|jsonapi/user/user(?:/[^/]+)?)$" \ + "id:900820,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/" + # 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 4f9f7d7..0cc9074 100644 --- a/internal/coreruleset/drupal_test.go +++ b/internal/coreruleset/drupal_test.go @@ -19,6 +19,12 @@ const drupalSignIn = "/user/login" // drupalJSONSignIn is Drupal's sign-in in its JSON format. const drupalJSONSignIn = "/user/login?_format=json" +// drupalRegister is Drupal's sign-up form. +const drupalRegister = "/user/register" + +// drupalJSONRegister is Drupal's sign-up in its JSON format. +const drupalJSONRegister = "/user/register?_format=json" + // drupalJSONPasswordReset is Drupal's password reset in its JSON format. const drupalJSONPasswordReset = "/user/password?_format=json" @@ -105,7 +111,7 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { {reply, drupalName + attack}, {drupalSignIn, drupalName + attack}, {"/fr/user/login", drupalName + attack}, - {"/user/register", drupalName + attack}, + {drupalRegister, drupalName + attack}, {drupalEditAccount, "mail=" + attack}, {drupalTagForm, "name%5B0%5D%5Bvalue%5D=" + attack}, {drupalTagForm, "description%5B0%5D%5Bvalue%5D=" + attack}, @@ -182,6 +188,28 @@ func TestDrupalPasswordPasses(t *testing.T) { } } +func TestDrupalPasswordFieldsPass(t *testing.T) { + t.Parallel() + + const value = "pass.0.value" + + pass := []string{"pass[pass1]", "pass[pass2]"} + + wantPasswordFieldsPass(t, []passwordFields{ + {drupalRegister, formData, pass}, + {"/fr/user/register", formData, pass}, + {"/user/1/edit", formData, []string{"current_pass", pass[0], pass[1]}}, + {"/admin/people/create", formData, pass}, + {drupalJSONRegister, jsonBody, []string{value}}, + {drupalJSONRegister, jsonBody, []string{"pass.value"}}, + {"/user/1?_format=json", jsonBody, []string{value, "pass.0.existing"}}, + { + "/jsonapi/user/user/86e2fbf4-0c01-4e2d-9c05-9a56e07f3e53", jsonBody, + []string{"data.attributes.pass.value", "data.attributes.pass.existing"}, + }, + }) +} + func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) { t.Parallel() @@ -274,7 +302,7 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) { {drupalAddArticle, "field_tags%5Btarget_id%5D="}, {drupalAddArticle, "field_image%5B0%5D%5Balt%5D="}, {drupalSignIn, drupalName}, - {"/user/register", drupalName}, + {drupalRegister, drupalName}, {drupalEditAccount, "mail="}, {"/comment/reply/node/1/comment", drupalName}, {drupalTagForm, "name%5B0%5D%5Bvalue%5D="}, @@ -467,7 +495,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) { // on a path other than the password reset. for _, target := range []string{ drupalJSONPasswordReset + "&json.name=python3", - "/user/register?_format=json", + drupalJSONRegister, } { r := request{http.MethodPost, target, []string{jsonBody}} @@ -480,7 +508,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) { // So does the JSON field mail. for _, target := range []string{ drupalJSONPasswordReset + "&json.mail=python3%40example.com", - "/user/register?_format=json", + drupalJSONRegister, } { r := request{http.MethodPost, target, []string{jsonBody}} diff --git a/internal/coreruleset/gitea.conf b/internal/coreruleset/gitea.conf index 6059b24..975f53a 100644 --- a/internal/coreruleset/gitea.conf +++ b/internal/coreruleset/gitea.conf @@ -289,3 +289,50 @@ SecRule REQUEST_FILENAME \ "@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \ "id:900640,phase:1,pass,nolog,\ ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range" + +# The passwords skip each rule that random passwords set off: +# password: signing in, signing up, linking an account, activating an account, +# resetting or changing the password, deleting an account, and creating or +# editing a user on the admin pages +# retype: signing up, linking an account by signing up, and changing the +# password +# old_password: changing the password +# json.password: creating or editing a user through the admin API +SecRule REQUEST_FILENAME "@rx ^/user/(?:login|sign_up|link_account_signin|link_account_signup|activate|recover_account|settings/(?:change_password|account(?:/delete)?))$|^/(?:-/)?admin/users/(?:new|[0-9]+/edit)$|^/api/v1/admin/users(?:/[^/]+)?$" \ + "id:900650,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:/^(?:password|retype|old_password|json\.password)$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype|old_password|json\.password)$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype|old_password|json\.password)$/" diff --git a/internal/coreruleset/mastodon.conf b/internal/coreruleset/mastodon.conf index 0b09f71..abcad65 100644 --- a/internal/coreruleset/mastodon.conf +++ b/internal/coreruleset/mastodon.conf @@ -176,45 +176,47 @@ 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)(?:/|$)" \ +# The passwords named above skip each rule that random passwords set off, and +# so does form_challenge[current_password]: confirming the password at +# /auth/challenge, or on the two-factor settings pages that ask for it again. +SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete|two_factor_authentication(?:_methods)?)|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 &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/" SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \ "id:900966,phase:1,pass,nolog,chain" diff --git a/internal/coreruleset/mastodon_test.go b/internal/coreruleset/mastodon_test.go index 19539fa..693777b 100644 --- a/internal/coreruleset/mastodon_test.go +++ b/internal/coreruleset/mastodon_test.go @@ -325,6 +325,18 @@ func TestMastodonPasswordsPass(t *testing.T) { }) } +func TestMastodonPasswordConfirmationPasses(t *testing.T) { + t.Parallel() + + challenge := []string{"form_challenge[current_password]"} + + wantPasswordFieldsPass(t, []passwordFields{ + {"/auth/challenge", formData, challenge}, + {"/settings/two_factor_authentication/otp_authentication", formData, challenge}, + {"/settings/two_factor_authentication_methods/disable", formData, challenge}, + }) +} + func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) { t.Parallel() diff --git a/internal/coreruleset/mediawiki.conf b/internal/coreruleset/mediawiki.conf index 7fbf9ae..10fc915 100644 --- a/internal/coreruleset/mediawiki.conf +++ b/internal/coreruleset/mediawiki.conf @@ -108,7 +108,12 @@ 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. +# These passwords skip each rule random passwords set off, on MediaWiki's pages: +# wpPassword: signing in and signing up +# and on /api.php: +# lgpassword: signing in with action=login +# password, retype: action=clientlogin, createaccount and +# changeauthenticationdata 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" @@ -151,42 +156,131 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \ 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)$/" + SecRule &ARGS_GET:/^(?:lgpassword|password|retype)$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password|retype)$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password|retype)$/" + +# Other apps use the names password and retype too, and these exclusions apply +# in front of every app. So these two fields skip the same rules only on the +# pages that take them: +# password, retype: changing the password at Special:ChangeCredentials +# retype: signing up at Special:CreateAccount +# retype: setting the new password after a reset at Special:UserLogin +# The path names the page, or the path is /index.php and title names it. A title +# on any other path does not count, so another app's paths under /index.php/ +# keep every rule. +SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/Special:ChangeCredentials(?:/|$)" \ + "id:900919,phase:1,pass,nolog,chain" + SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?Special:ChangeCredentials(?:/|$)" "chain" + SecRule &ARGS_GET:/^(?:password|retype)$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype)$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype)$/" + +SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/Special:(?:CreateAccount|UserLogin)(?:/|$)" \ + "id:900920,phase:1,pass,nolog,chain" + SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?Special:(?:CreateAccount|UserLogin)(?:/|$)" "chain" + SecRule &ARGS_GET:retype "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:retype,\ + ctl:ruleRemoveTargetById=920540;ARGS:retype,\ + ctl:ruleRemoveTargetById=921200;ARGS:retype,\ + ctl:ruleRemoveTargetById=930100;ARGS:retype,\ + ctl:ruleRemoveTargetById=930110;ARGS:retype,\ + ctl:ruleRemoveTargetById=930120;ARGS:retype,\ + ctl:ruleRemoveTargetById=932125;ARGS:retype,\ + ctl:ruleRemoveTargetById=932130;ARGS:retype,\ + ctl:ruleRemoveTargetById=932160;ARGS:retype,\ + ctl:ruleRemoveTargetById=932230;ARGS:retype,\ + ctl:ruleRemoveTargetById=932250;ARGS:retype,\ + ctl:ruleRemoveTargetById=932260;ARGS:retype,\ + ctl:ruleRemoveTargetById=932270;ARGS:retype,\ + ctl:ruleRemoveTargetById=932280;ARGS:retype,\ + ctl:ruleRemoveTargetById=932330;ARGS:retype,\ + ctl:ruleRemoveTargetById=932340;ARGS:retype,\ + ctl:ruleRemoveTargetById=932370;ARGS:retype,\ + ctl:ruleRemoveTargetById=932380;ARGS:retype,\ + ctl:ruleRemoveTargetById=933100;ARGS:retype,\ + ctl:ruleRemoveTargetById=933135;ARGS:retype,\ + ctl:ruleRemoveTargetById=933180;ARGS:retype,\ + ctl:ruleRemoveTargetById=933210;ARGS:retype,\ + ctl:ruleRemoveTargetById=941100;ARGS:retype,\ + ctl:ruleRemoveTargetById=941120;ARGS:retype,\ + ctl:ruleRemoveTargetById=941160;ARGS:retype,\ + ctl:ruleRemoveTargetById=941180;ARGS:retype,\ + ctl:ruleRemoveTargetById=942100;ARGS:retype,\ + ctl:ruleRemoveTargetById=942151;ARGS:retype,\ + ctl:ruleRemoveTargetById=942190;ARGS:retype,\ + ctl:ruleRemoveTargetById=942230;ARGS:retype,\ + ctl:ruleRemoveTargetById=942290;ARGS:retype,\ + ctl:ruleRemoveTargetById=942320;ARGS:retype,\ + ctl:ruleRemoveTargetById=942540;ARGS:retype,\ + ctl:ruleRemoveTargetById=942550;ARGS:retype,\ + ctl:ruleRemoveTargetById=944150;ARGS:retype" # 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 diff --git a/internal/coreruleset/mediawiki_test.go b/internal/coreruleset/mediawiki_test.go index 2e70892..8e0ae88 100644 --- a/internal/coreruleset/mediawiki_test.go +++ b/internal/coreruleset/mediawiki_test.go @@ -25,6 +25,12 @@ const mediawikiUpload = "/wiki/Special:Upload" // mediawikiSignIn is the address MediaWiki's sign-in form posts to. const mediawikiSignIn = "/index.php?title=Special:UserLogin" +// mediawikiAccountChange is the page that changes a password. +const mediawikiAccountChange = "/wiki/Special:ChangeCredentials" + +// mediawikiSignUp is the page that creates an account. +const mediawikiSignUp = "/wiki/Special:CreateAccount" + // mediawikiMove is the address MediaWiki's form for moving a page posts to. const mediawikiMove = "/index.php?title=Special:MovePage&action=submit" @@ -236,6 +242,60 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { } } +func TestMediaWikiSignUpAndPasswordChangeFieldsPass(t *testing.T) { + t.Parallel() + + // The form that changes a password posts to this subpage. + const subpage = "/MediaWiki%5CAuth%5CPasswordAuthenticationRequest" + + change := []string{passwordKey, retypeKey} + retype := []string{retypeKey} + + wantPasswordFieldsPass(t, []passwordFields{ + {mediawikiSignUp, formData, retype}, + {"/index.php/Special:CreateAccount", formData, retype}, + {"/index.php?title=Special:CreateAccount&returnto=Main_Page", formData, retype}, + {"/wiki/Special:UserLogin", formData, retype}, + {mediawikiSignIn, formData, retype}, + {mediawikiAccountChange, formData, change}, + {mediawikiAccountChange + subpage, formData, change}, + {"/index.php/Special:ChangeCredentials", formData, change}, + {"/index.php?title=Special:ChangeCredentials" + subpage, formData, change}, + {"/w/index.php?title=Special:ChangeCredentials", formData, change}, + {mediawikiAPI, formData, retype}, + }) +} + +// Other apps use the names password and retype too. So these fields keep every +// rule on any page that does not take them, whatever title the query names. +func TestMediaWikiPasswordFieldsStayCheckedOffTheirPages(t *testing.T) { + t.Parallel() + + const appPath = "/index.php/apps/mail/api/accounts" + + crs := readingBodies(t) + + for _, sent := range []struct{ target, name string }{ + {appPath, passwordKey}, + {appPath, retypeKey}, + {appPath + "?title=Special:ChangeCredentials", passwordKey}, + {appPath + "?title=Special:CreateAccount", retypeKey}, + {"/index.php?title=Main_Page", passwordKey}, + {"/index.php/Main_Page", retypeKey}, + {"/wiki/Main_Page", passwordKey}, + {"/wiki/Special:ChangeCredentialsExample", passwordKey}, + {mediawikiSignUp, passwordKey}, + {"/index.php?title=Special:CreateAccount", passwordKey}, + } { + r := request{http.MethodPost, sent.target, []string{formData}} + + body := formBody(sent.name, "' OR '1'='1") + if got, _ := inspectBody(t, crs, r, body); got.Score < mediawikiThreshold { + t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got) + } + } +} + func TestMediaWikiPasswordsPass(t *testing.T) { t.Parallel() diff --git a/internal/coreruleset/nextcloud.conf b/internal/coreruleset/nextcloud.conf index 96f675f..80ab8ce 100644 --- a/internal/coreruleset/nextcloud.conf +++ b/internal/coreruleset/nextcloud.conf @@ -101,6 +101,101 @@ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \ ctl:ruleRemoveTargetById=942550;ARGS:password,\ ctl:ruleRemoveTargetById=944150;ARGS:password" +# The other passwords, in form data or JSON, skip the same rules: +# password: confirming the password, resetting it, checking a sign-in with +# person/check, a public share's password, setting a share's password or +# sending it by email, creating or editing a user, and an administrator +# changing a user's password +# oldpassword, newpassword: changing the password on the settings page +# oldPassword, newPassword: changing an expired LDAP password, and changing +# the password of the encryption app's private key or recovery key +# recoveryPassword: an administrator changing the password of an encrypted +# account, or setting the encryption app's recovery key +# confirmPassword: the encryption app's recovery key, typed again +SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?(?:login/confirm|lostpassword/set/[^/]+/[^/]+|settings/(?:personal|users)/changepassword|s/[^/]+/authenticate/[^/]+|apps/user_ldap/renewpassword|apps/encryption/ajax/(?:updatePrivateKeyPassword|changeRecoveryPassword|adminRecovery))$|^/ocs/v[12]\.php/(?:person/check|core/apppassword/confirm|cloud/users(?:/[^/]+)?|apps/files_sharing/api/v1/shares(?:/[^/]+(?:/send-email)?)?)$" \ + "id:900126,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/" + +# Editing a user through the OCS API can name the password as the field to +# change: key=password&value=..., or the same in JSON. This rule is in phase 2, +# because a phase 1 rule runs before the body is read. It checks that the body +# sends key once, so a second key cannot skip these rules for another field. +SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/cloud/users/[^/]+$" \ + "id:900127,phase:2,pass,nolog,chain" + SecRule &ARGS_GET:/^(?:json\.)?(?:key|value)$/ "@eq 0" "chain" + SecRule &ARGS_POST:/^(?:json\.)?key$/ "@eq 1" "chain" + SecRule ARGS_POST:/^(?:json\.)?key$/ "@streq password" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?value$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?value$/" + # 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 7386404..0ab7742 100644 --- a/internal/coreruleset/nextcloud_test.go +++ b/internal/coreruleset/nextcloud_test.go @@ -334,6 +334,111 @@ func TestNextcloudPasswordPasses(t *testing.T) { wantPasswordAttacksRefused(t, r, "password") } +func TestNextcloudPasswordFieldsPass(t *testing.T) { + t.Parallel() + + password := []string{passwordKey} + change := []string{"oldPassword", "newPassword"} + recovery := []string{"oldPassword", "newPassword", "confirmPassword"} + adminRecovery := []string{"recoveryPassword", "confirmPassword"} + + wantPasswordFieldsPass(t, []passwordFields{ + {"/ocs/v1.php/person/check", formData, password}, + {"/ocs/v2.php/person/check", jsonBody, password}, + {"/ocs/v2.php/core/apppassword/confirm", jsonBody, password}, + {nextcloudShares + "/42/send-email", jsonBody, password}, + {"/apps/user_ldap/renewpassword", formData, change}, + {"/index.php/apps/user_ldap/renewpassword", formData, change}, + {"/apps/encryption/ajax/updatePrivateKeyPassword", formData, change}, + {"/index.php/apps/encryption/ajax/updatePrivateKeyPassword", formData, change}, + {"/apps/encryption/ajax/changeRecoveryPassword", formData, recovery}, + {"/index.php/apps/encryption/ajax/changeRecoveryPassword", formData, recovery}, + {"/apps/encryption/ajax/adminRecovery", formData, adminRecovery}, + {"/index.php/apps/encryption/ajax/adminRecovery", formData, adminRecovery}, + {"/login/confirm", jsonBody, password}, + {"/index.php/login/confirm", formData, password}, + {"/lostpassword/set/AbCdEf/alice", jsonBody, password}, + { + "/settings/personal/changepassword", jsonBody, + []string{"oldpassword", "newpassword"}, + }, + { + "/index.php/settings/users/changepassword", formData, + []string{passwordKey, "recoveryPassword"}, + }, + {nextcloudShare + "/authenticate/showShare", formData, password}, + {nextcloudUsers, jsonBody, password}, + {"/ocs/v1.php/cloud/users/alice", formData, password}, + {nextcloudUsers + "/alice", jsonBody, password}, + {nextcloudShares, jsonBody, password}, + {nextcloudShares + "/42", formData, password}, + }) +} + +// TestNextcloudUserPasswordInValuePasses sends a password as Nextcloud's +// provisioning API takes it, as the value of the field the key names. +func TestNextcloudUserPasswordInValuePasses(t *testing.T) { + t.Parallel() + + const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default + + crs := readingBodies(t) + target := nextcloudUsers + "/alice" + + form := request{http.MethodPut, target, []string{formData}} + jsonRequest := request{http.MethodPut, target, []string{jsonBody}} + + formPassword := func(passwords ...string) string { + return "key=password&" + formBody("value", passwords...) + } + jsonPassword := func(passwords ...string) string { + body := passwordBody(t, jsonRequest, []string{"value"}, passwords...) + + return `{"key":"password",` + body[1:] + } + + misfiring := misfiringPasswords(t) + + for _, sent := range []struct { + r request + body string + }{ + {form, formPassword(misfiring...)}, + {jsonRequest, jsonPassword(misfiring...)}, + {form, formPassword("' OR '1'='1", "")}, + {jsonRequest, jsonPassword("; cat /etc/passwd")}, + } { + if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score >= threshold { + t.Errorf("%s %.40s refused: %+v", sent.r.headers[0], sent.body, got) + } + } + + // Any other key keeps every rule, and so does a second key. + for _, sent := range []struct { + r request + body string + }{ + {form, "key=displayname&value=" + url.QueryEscape("' OR '1'='1")}, + {form, formPassword(scriptTag)}, + {form, "key=password&key=displayname&value=" + url.QueryEscape("' OR '1'='1")}, + {jsonRequest, `{"key":"email","value":"' OR '1'='1"}`}, + } { + if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score < threshold { + t.Errorf("%s %.40s let through: %+v", sent.r.headers[0], sent.body, got) + } + } + + // A value in the query keeps every rule. + r := request{ + http.MethodPut, target + "?value=%27%20OR%20%271%27%3D%271", + []string{formData}, + } + + if got, _ := inspectBody(t, crs, r, "key=password"); got.Score < threshold { + t.Errorf("%s let through: %+v", r.target, got) + } +} + func TestNextcloudSentencesPassInTheTexts(t *testing.T) { t.Parallel() diff --git a/internal/coreruleset/vaultwarden.conf b/internal/coreruleset/vaultwarden.conf index cb7eb19..6463e97 100644 --- a/internal/coreruleset/vaultwarden.conf +++ b/internal/coreruleset/vaultwarden.conf @@ -82,3 +82,45 @@ SecRule REQUEST_FILENAME "@rx ^/api/hibp/breach$" \ SecRule REQUEST_FILENAME "@rx ^/api/ciphers/[0-9a-f-]{36}/attachment(?:-admin|/[0-9a-f]+(?:/share)?)?$|^/api/sends/[0-9a-f-]{36}/file/[0-9a-f]+$" \ "id:900153,phase:1,pass,nolog,\ ctl:ruleRemoveTargetById=920120;FILES" + +# The admin token, which an administrator types at /admin, skips each rule that +# random passwords set off. The master password is hashed by the apps before +# they send it, so password and masterPasswordHash need no exclusion. +SecRule REQUEST_FILENAME "@rx ^/admin/?$" \ + "id:900154,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:token "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:token,\ + ctl:ruleRemoveTargetById=920540;ARGS:token,\ + ctl:ruleRemoveTargetById=921200;ARGS:token,\ + ctl:ruleRemoveTargetById=930100;ARGS:token,\ + ctl:ruleRemoveTargetById=930110;ARGS:token,\ + ctl:ruleRemoveTargetById=930120;ARGS:token,\ + ctl:ruleRemoveTargetById=932125;ARGS:token,\ + ctl:ruleRemoveTargetById=932130;ARGS:token,\ + ctl:ruleRemoveTargetById=932160;ARGS:token,\ + ctl:ruleRemoveTargetById=932230;ARGS:token,\ + ctl:ruleRemoveTargetById=932250;ARGS:token,\ + ctl:ruleRemoveTargetById=932260;ARGS:token,\ + ctl:ruleRemoveTargetById=932270;ARGS:token,\ + ctl:ruleRemoveTargetById=932280;ARGS:token,\ + ctl:ruleRemoveTargetById=932330;ARGS:token,\ + ctl:ruleRemoveTargetById=932340;ARGS:token,\ + ctl:ruleRemoveTargetById=932370;ARGS:token,\ + ctl:ruleRemoveTargetById=932380;ARGS:token,\ + ctl:ruleRemoveTargetById=933100;ARGS:token,\ + ctl:ruleRemoveTargetById=933135;ARGS:token,\ + ctl:ruleRemoveTargetById=933180;ARGS:token,\ + ctl:ruleRemoveTargetById=933210;ARGS:token,\ + ctl:ruleRemoveTargetById=941100;ARGS:token,\ + ctl:ruleRemoveTargetById=941120;ARGS:token,\ + ctl:ruleRemoveTargetById=941160;ARGS:token,\ + ctl:ruleRemoveTargetById=941180;ARGS:token,\ + ctl:ruleRemoveTargetById=942100;ARGS:token,\ + ctl:ruleRemoveTargetById=942151;ARGS:token,\ + ctl:ruleRemoveTargetById=942190;ARGS:token,\ + ctl:ruleRemoveTargetById=942230;ARGS:token,\ + ctl:ruleRemoveTargetById=942290;ARGS:token,\ + ctl:ruleRemoveTargetById=942320;ARGS:token,\ + ctl:ruleRemoveTargetById=942540;ARGS:token,\ + ctl:ruleRemoveTargetById=942550;ARGS:token,\ + ctl:ruleRemoveTargetById=944150;ARGS:token" diff --git a/internal/coreruleset/vaultwarden_test.go b/internal/coreruleset/vaultwarden_test.go index 0d9f58f..12069e9 100644 --- a/internal/coreruleset/vaultwarden_test.go +++ b/internal/coreruleset/vaultwarden_test.go @@ -1,7 +1,9 @@ package coreruleset_test import ( + "encoding/base64" "encoding/json" + "math/rand/v2" "net/http" "net/url" "slices" @@ -277,6 +279,73 @@ func vaultwardenUpload(name string) string { "\"\r\nContent-Type: application/octet-stream\r\n\r\nx\r\n" + end } +func TestVaultwardenAdminTokenPasses(t *testing.T) { + t.Parallel() + + wantPasswordFieldsPass(t, []passwordFields{ + {"/admin", formData, []string{"token"}}, + }) +} + +// masterPasswordHashes returns count random 32-byte hashes in base64, the +// form in which the apps send a master password. +func masterPasswordHashes(count int) []string { + const size, seed = 32, 288 + + random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample + + hashes := make([]string, count) + + for i := range hashes { + hash := make([]byte, size) + for j := range hash { + hash[j] = byte(random.IntN(1 << 8)) //nolint:gosec // below 256 + } + + hashes[i] = base64.StdEncoding.EncodeToString(hash) + } + + return hashes +} + +// The apps hash the master password before they send it, so these fields +// hold only base64 and set off no rule, with no exclusion. +func TestVaultwardenMasterPasswordHashesPass(t *testing.T) { + t.Parallel() + + const perBody = 20 + + crs := readingBodies(t) + hashes := masterPasswordHashes(perBody) + + for _, sent := range []struct { + r request + names []string + }{ + { + request{http.MethodPost, "/identity/connect/token", []string{formData}}, + []string{passwordKey}, + }, + { + request{http.MethodPost, vaultwardenPassword, []string{jsonBody}}, + []string{"masterPasswordHash", "newMasterPasswordHash"}, + }, + { + request{http.MethodPost, "/api/accounts/delete", []string{jsonBody}}, + []string{"masterPasswordHash"}, + }, + } { + body := passwordBody(t, sent.r, sent.names, hashes...) + if len(body) > bodyLimit { + t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body)) + } + + if got, _ := inspectBody(t, crs, sent.r, body); len(got.RuleIDs) != 0 { + t.Errorf("POST %s %v set off rules: %+v", sent.r.target, sent.names, got) + } + } +} + func TestVaultwardenEncryptedFileNamesPassOnlyOnUploads(t *testing.T) { t.Parallel() diff --git a/internal/coreruleset/wordpress.conf b/internal/coreruleset/wordpress.conf index 7378d5d..3a17b71 100644 --- a/internal/coreruleset/wordpress.conf +++ b/internal/coreruleset/wordpress.conf @@ -136,6 +136,92 @@ SecRule REQUEST_FILENAME "@streq /wp-login.php" \ ctl:ruleRemoveTargetById=942550;ARGS:pwd,\ ctl:ruleRemoveTargetById=944150;ARGS:pwd" +# The other passwords, which skip the same rules: +# pass1, pass2: resetting the password at /wp-login.php, changing it on the +# profile page and the page that edits a user, and adding a user; a +# multisite install also changes it on network/user-edit.php and +# user/profile.php +# post_password: the password of a protected post, sent to /wp-login.php +SecRule REQUEST_FILENAME "@rx ^/(?:wp-login\.php|wp-admin/(?:profile|user-edit|user-new|network/user-edit|user/profile)\.php)$" \ + "id:900715,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:/^(?:pass[12]|post_password)$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:pass[12]|post_password)$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:pass[12]|post_password)$/" + +# Creating or editing a user through the REST API, in password, in form data or +# JSON, which skips the same rules. +SecRule TX:rest_route "@rx ^/wp/v2/users(?:/(?:me|[0-9]+))?$" \ + "id:900716,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$/" + # 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 # 932120 and 932260: -- 2.54.0