Core Rule Set: Ghost's remaining fields pass with words that name commands (closes #359)
check / check (push) Waiting to run
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
This commit is contained in:
1 parent
c4bf76c966
commit
799ae441b4
5 files changed
+805
-42
No files matched your search
@@ -28,6 +28,19 @@ const (
|
||||
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) {
|
||||
@@ -64,7 +77,7 @@ func (f ghostField) with(t *testing.T, value string) string {
|
||||
)
|
||||
}
|
||||
|
||||
// ghostTextFields hold plain text and skip the rules of 900353 and 900359.
|
||||
// ghostTextFields hold plain text and skip the eight rules that 900353 lists.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostTextFields = []ghostField{
|
||||
@@ -116,8 +129,111 @@ var ghostTextFields = []ghostField{
|
||||
`{"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"}`
|
||||
|
||||
@@ -135,6 +251,8 @@ var ghostAddressFields = []ghostField{
|
||||
{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,
|
||||
@@ -143,6 +261,40 @@ var ghostAddressFields = []ghostField{
|
||||
{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
|
||||
// <script> element by 900366.
|
||||
//
|
||||
@@ -183,8 +335,8 @@ var ghostArticleFields = slices.Concat(
|
||||
ghostSnippetAndNewsletterFooterFields,
|
||||
)
|
||||
|
||||
// ghostCodeFields hold code injection or a setting, skip the rules of 900356,
|
||||
// and take a <script> element by 900366.
|
||||
// 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{
|
||||
@@ -203,6 +355,16 @@ var ghostCodeFields = []ghostField{
|
||||
`{"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 "=".
|
||||
@@ -211,16 +373,42 @@ const (
|
||||
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,
|
||||
}
|
||||
|
||||
// ghostURLs are addresses 930120 and 932120 refused in url.
|
||||
// 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{
|
||||
@@ -238,6 +426,15 @@ var ghostAddresses = []string{
|
||||
"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>` +
|
||||
@@ -327,14 +524,24 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
||||
|
||||
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 ghostAddressFields {
|
||||
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)
|
||||
}
|
||||
@@ -343,6 +550,14 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
||||
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]",
|
||||
@@ -376,7 +591,8 @@ func TestGhostScriptsPassInPostsAndCodeInjection(t *testing.T) {
|
||||
wantGhostField(t, f, ghostWidget, false)
|
||||
}
|
||||
|
||||
// Site code injection, saved in settings, and a post's or a page's own.
|
||||
// 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)
|
||||
}
|
||||
@@ -395,6 +611,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
|
||||
for _, attack := range ghostAttacks {
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
||||
ghostArticleFields, ghostCodeFields) {
|
||||
wantGhostField(t, f, attack, true)
|
||||
}
|
||||
@@ -407,6 +624,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
// 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)
|
||||
}
|
||||
@@ -415,13 +633,19 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
wantGhostQuery(t, target+url.QueryEscape(scriptTag), 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) {
|
||||
// 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 ghostQueryFields {
|
||||
for _, target := range []string{
|
||||
ghostSearch, ghostEmbed, ghostPathname, ghostSegment,
|
||||
ghostInvitedEmail,
|
||||
} {
|
||||
wantGhostQuery(
|
||||
t,
|
||||
target+url.QueryEscape(commandAfterASeparator),
|
||||
@@ -429,6 +653,15 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -440,12 +673,138 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
|
||||
}
|
||||
|
||||
// An address keeps 932250, which refuses a listing of files.
|
||||
for _, f := range ghostAddressFields {
|
||||
// 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()
|
||||
|
||||
@@ -481,7 +840,6 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
`{"settings":[{"value":"<meta name=x content=y>"}]}`,
|
||||
},
|
||||
{"/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"}]}`},
|
||||
@@ -533,6 +891,7 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
|
||||
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 }{
|
||||
@@ -542,6 +901,8 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
|
||||
{"/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}}
|
||||
|
||||
@@ -563,7 +924,8 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
||||
|
||||
// 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.
|
||||
// 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",
|
||||
@@ -572,11 +934,79 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
||||
"/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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user