diff --git a/README.md b/README.md
index b488122..b5f051b 100644
--- a/README.md
+++ b/README.md
@@ -610,8 +610,9 @@ lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
its searches, its file names, its account names, its comments and its
documents;
- `ghost`, whose fixes let through its filters, its searches, its names, its
- email addresses, its code injection, its settings and a post saved from
- its editor, HTML included.
+ 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;
diff --git a/SPEC.md b/SPEC.md
index 6ac58b1..5b4b659 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -1747,31 +1747,62 @@ Ghost's fixes: on Ghost's paths, some fields skip the rules that refused
ordinary Ghost requests there. `internal/coreruleset/ghost.conf` lists each rule
and the request it refused. Ghost's admin and integrations call the Admin API
under `/ghost/api/admin/`. Themes and Ghost's scripts call the Content API under
-`/ghost/api/content/`, and members call `/members/api/`.
+`/ghost/api/content/`, and members call `/members/api/`. The admin calls
+ActivityPub, a service of its own, under `/.ghost/activitypub/`.
-Three query fields skip some rules:
+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`.
-- `filter`, a query in Ghost's own filter language, skips 932120 on the Admin
- API and the Content API, so `tag:powershell` passes;
+These query fields skip some rules:
+
+- `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.
+ 930120 and 932120;
+- `pathname`, `source` and the `utm_` fields, which filter the analytics, skip
+ 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 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 930120, 932120,
-932125, 932230, 932250, 932260 and 932380. 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;
- `custom_excerpt`, `description`, `note` and `sender_name`;
- the `meta_`, `og_` and `twitter_` titles and descriptions.
-A member's `name` at `/members/api/` skips them too. 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: `username` when signing in, and
-`email` when creating the first staff user, asking for a password reset or
-saving a profile.
+These fields on the Admin API skip them too:
+
+- a staff user's `name`, `slug`, `bio`, `location` and `website`;
+- `blogTitle` and `name` when creating the first staff user;
+- `name` when accepting an invitation;
+- `name`, `description` and `benefits` of tiers;
+- `name`, `code`, `display_title` and `display_description` of offers;
+- `title`, `description` and `excerpt` of recommendations;
+- `name` and `description` of integrations, and `name` of webhooks;
+- `feature_image_alt` and `feature_image_caption` of posts and pages.
+
+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;
+- 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:
+`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:
@@ -1791,20 +1822,24 @@ The articles skip these rules:
Code injection is the HTML that Ghost adds to the head and the foot of a page.
These fields on the Admin API hold it:
-- `codeinjection_head` and `codeinjection_foot` of posts and pages;
+- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
- `value` of settings, since the site's settings hold its code injection.
Code injection skips these rules:
-- 930120, 932120, 932125, 932230, 932250, 932260 and 932380, as plain text does;
+- the rules of plain text except 932235;
- 921130, 941100, 941160 and 941270, which refused `` and ``
elements.
`` 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.
+outside the articles.
+
+`; 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`.
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..df28a69 100644
--- a/internal/coreruleset/ghost.conf
+++ b/internal/coreruleset/ghost.conf
@@ -7,7 +7,8 @@
#
# Ghost's admin and its integrations call the Admin API under /ghost/api/admin/,
# themes and Ghost's own scripts call the Content API under /ghost/api/content/,
-# and members call /members/api/. Coraza names a field of a JSON body by its
+# and members call /members/api/. The admin calls ActivityPub, a service of its
+# own, under /.ghost/activitypub/. Coraza names a field of a JSON body by its
# path, so the title of the first post saved is json.posts.0.title.
# Every rule below is skipped unless Inspect has set tx.app_ghost. It sets it
@@ -17,11 +18,22 @@
SecRule &TX:app_ghost "@eq 0" \
"id:900365,phase:1,pass,nolog,skipAfter:END_GHOST"
-# filter, a query in Ghost's own filter language, skips 932120 on the Admin API
-# and the Content API: filter=tag:powershell, filter=name:~'powershell'.
+# filter, a query in Ghost's own filter language, skips seven rules on the
+# Admin API and the Content API:
+# 932120: filter=tag:powershell, filter=name:~'powershell'
+# 932125, 932230, 932250: filter=title:~'powershell & ls -la'
+# 932235, 932260: filter=title:~'powershell & python3', which the admin's
+# post picker sends when a person types that title
+# 932380: filter=title:~'python3 & powershell'
SecRule REQUEST_FILENAME "@rx ^/ghost/api/(?:admin|content)/" \
"id:900350,phase:1,pass,nolog,\
- ctl:ruleRemoveTargetById=932120;ARGS:filter"
+ ctl:ruleRemoveTargetById=932120;ARGS:filter,\
+ ctl:ruleRemoveTargetById=932125;ARGS:filter,\
+ ctl:ruleRemoveTargetById=932230;ARGS:filter,\
+ ctl:ruleRemoveTargetById=932235;ARGS:filter,\
+ ctl:ruleRemoveTargetById=932250;ARGS:filter,\
+ ctl:ruleRemoveTargetById=932260;ARGS:filter,\
+ ctl:ruleRemoveTargetById=932380;ARGS:filter"
# search, the search for a member by name or address, skips four rules on the
# Admin API:
@@ -45,10 +57,51 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/oembed/" \
ctl:ruleRemoveTargetById=930120;ARGS:url,\
ctl:ruleRemoveTargetById=932120;ARGS:url"
-# The fields a person types plain text into skip seven rules on the Admin API:
+# 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
+# 932250: utm_term=ls -la
+# These fields filter the figures by a page, the site a visit came from, or a
+# campaign: pathname, source, utm_source, utm_medium, utm_campaign,
+# utm_content and utm_term.
+SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/stats/" \
+ "id:900367,phase:1,pass,nolog,\
+ ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
+ ctl:ruleRemoveTargetById=932120;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
+ ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
+ ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
+ ctl:ruleRemoveTargetById=932235;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
+ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
+ ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
+ ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/"
+
+# email_segment, the members a post is sent to as a newsletter, skips 932120
+# on the Admin API's posts: email_segment=label:powershell-users.
+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 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,\
+ ctl:ruleRemoveTargetById=932120;ARGS:query,\
+ ctl:ruleRemoveTargetById=932125;ARGS:query,\
+ ctl:ruleRemoveTargetById=932230;ARGS:query,\
+ ctl:ruleRemoveTargetById=932235;ARGS:query,\
+ ctl:ruleRemoveTargetById=932250;ARGS:query,\
+ ctl:ruleRemoveTargetById=932260;ARGS:query,\
+ ctl:ruleRemoveTargetById=932380;ARGS:query"
+
+# 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"
+# 932235: the title "powershell & python3"
# 932260: the tag python3
# 932380: the newsletter "Shell tips weekly: python3 & powershell"
# These fields hold plain text:
@@ -64,11 +117,12 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
+ ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/"
-# name, the name a member saves at /members/api/, skips the same seven rules.
+# name, the name a member saves at /members/api/, skips the same eight rules.
SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
"id:900359,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.name$/ "@eq 0" \
@@ -76,10 +130,96 @@ SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.name$/,\
+ ctl:ruleRemoveTargetById=932235;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.name$/"
+# More fields of plain text skip the same eight rules on the Admin API, which
+# refused "python3 & powershell club", "Weekly ls -la and .htaccess tips" and
+# "powershell & python3 deal" in them:
+# users..name, slug, bio, location and website, a staff user's profile
+# setup..blogTitle and name, creating the first staff user
+# invitation..name, accepting a staff invitation
+# tiers..name, description and benefits
+# offers..name, code, display_title and display_description
+# recommendations..title, description and excerpt
+# integrations..name and description, and webhooks..name
+# posts..feature_image_alt and feature_image_caption, and the same fields
+# of pages
+# Coraza matches a field's name in lower case, so blogTitle is blogtitle.
+SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
+ "id:900370,phase:1,pass,nolog,chain"
+ SecRule &ARGS_GET:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/ "@eq 0" \
+ "ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
+ ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
+ ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
+ ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
+ ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
+ ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
+ ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
+ ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/"
+
+# A member's comment, html at /members/api/comments/, skips the same eight
+# rules, which refused "I ran ls -la and fixed my .htaccess with python3". The
+# rules for scripts and HTML still refuse a