package coreruleset_test import ( "encoding/json" "net/http" "os" "path/filepath" "strings" "testing" ) // drupalRequest is a line of testdata/drupal-requests.txt. type drupalRequest struct { Method string `json:"method"` Target string `json:"target"` Headers []string `json:"headers"` Body string `json:"body"` } // drupalRequests returns the recorded ordinary Drupal requests. func drupalRequests(t *testing.T) []drupalRequest { t.Helper() text, err := os.ReadFile(filepath.Join("testdata", "drupal-requests.txt")) if err != nil { t.Fatal(err) } var requests []drupalRequest for line := range strings.Lines(string(text)) { if strings.HasPrefix(line, "#") { continue } var r drupalRequest err := json.Unmarshal([]byte(line), &r) if err != nil { t.Fatalf("%q: %v", line, err) } requests = append(requests, r) } return requests } // 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" func TestOrdinaryDrupalRequestsPass(t *testing.T) { t.Parallel() defaults, bodies := atDefaults(t), readingBodies(t) for _, recorded := range drupalRequests(t) { 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{ "1'%20OR%20'1'='1", "..%2F..%2Fsites%2Fdefault%2Fsettings.php", "%3Cscript%3Ealert(1)%3C%2Fscript%3E", } 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=", } { 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}, {"/user/login", "pass=" + 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 + `"}`, } { 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) } } } } 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", } { 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", } { 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"}, {"/user/login", "pass=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) } } }