From c7696b4c11a2c5e751622e29e35ba44ebd421f05 Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 12 Nov 2026 04:56:13 +0000 Subject: [PATCH] Core Rule Set: Ghost's remaining fields pass with words that name commands (closes #359) Ghost 6.76.0 was sent the fields not yet recorded with python3, powershell, ls -la and .htaccess. Fixes in ghost.conf: - eight rules that refused words naming commands and files, 932235 among them, for staff profiles, setup, invitations, tiers, offers, recommendations, integrations, webhooks, feature images, comments, the CSV import's labels, ActivityPub, and source and the utm_ fields; - 932235 for the existing plain-text fields and code injection; - filter skips six more rules, for title:~'powershell & python3'; - pathname skips 930120, 932120 and 932260; - invitation addresses, email_segment, a tag's canonical_url, and ActivityPub's handles, actorUri and domain; - a tag's code injection skips what a post's does. Model: opus-5-5 --- README.md | 6 +- SPEC.md | 103 +++- internal/coreruleset/ghost.conf | 256 +++++++++- internal/coreruleset/ghost_test.go | 462 +++++++++++++++++- .../coreruleset/testdata/ghost-requests.txt | 30 ++ 5 files changed, 803 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index b488122..27facce 100644 --- a/README.md +++ b/README.md @@ -609,9 +609,9 @@ 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 its filters, its searches, its names, its - email addresses, its code injection, its settings and a post saved from - its editor, HTML included. + - `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. - 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 d13884d..05203d8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1747,35 +1747,87 @@ 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: +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. -- `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 those eight rules + 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; +- `source` and the `utm_` fields, which filter the analytics, skip those eight + rules under `/ghost/api/admin/stats/`; +- `pathname`, the page the analytics show, skips 930120, 932120 and 932260 + there; +- `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; +- `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 those eight +rules. 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: -The `canonical_url` of a post or a page skips 930120 and 932120, as `url` does. -A post's skips them under `/ghost/api/admin/posts/`, and a page's under -`/ghost/api/admin/pages/`. +- 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; +- `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. + +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 and its address skip 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, when it is added at + `/.ghost/activitypub/v1/aliases`; +- `actorUri`, that account's address, when the admin removes it there; +- `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 `canonical_url` of a post, a page or a tag skips 930120 and 932120, as `url` +does. Each skips them only on its own path: + +- a post's under `/ghost/api/admin/posts/`; +- a page's under `/ghost/api/admin/pages/`; +- a tag's under `/ghost/api/admin/tags/`. The articles carry HTML on purpose. These fields on the Admin API hold them: @@ -1795,12 +1847,14 @@ 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; +- 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`; - 921130, 941100, 941160 and 941270, which refused `` and `` elements. @@ -1809,15 +1863,22 @@ card can embed a widget and code injection can load analytics. These are the fields, on the Admin API: - `lexical`, `mobiledoc` and `html` of posts and pages; -- `codeinjection_head` and `codeinjection_foot` of posts and pages; +- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags; - `value` of settings. `` 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, the addresses and the canonical URLs. + +`; wget http://example.com/x.sh` is still refused in these fields: + +- the addresses, the handles and the domain; +- the `canonical_url` of posts, pages and tags; +- `search`, `url`, `pathname`, `email_segment` and `email`. + +The other fields above skip 932235, which refuses that command but 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 edf796d..52142af 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,61 @@ 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 that filter the analytics skip the eight rules that 900353 +# lists, on /ghost/api/admin/stats/: +# 932120: source=powershell.example.com +# 932235, 932260: utm_campaign=powershell & python3 +# 932250: utm_term=ls -la +# These fields filter the figures by the site a visit came from, or a +# campaign: 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:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\ + ctl:ruleRemoveTargetById=932120;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\ + ctl:ruleRemoveTargetById=932125;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\ + ctl:ruleRemoveTargetById=932230;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\ + ctl:ruleRemoveTargetById=932235;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\ + ctl:ruleRemoveTargetById=932250;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\ + ctl:ruleRemoveTargetById=932260;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\ + ctl:ruleRemoveTargetById=932380;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/" + +# pathname, the page the analytics show, skips three rules on +# /ghost/api/admin/stats/, which refused the path of a post and of tag pages: +# 930120: pathname=/tag/proc/ +# 932120: pathname=/python3-and-powershell/ +# 932260: pathname=/tag/python3/ +SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/stats/" \ + "id:900385,phase:1,pass,nolog,\ + ctl:ruleRemoveTargetById=930120;ARGS:pathname,\ + ctl:ruleRemoveTargetById=932120;ARGS:pathname,\ + ctl:ruleRemoveTargetById=932260;ARGS:pathname" + +# 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 that +# 900353 lists: 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 on the Admin API, +# which refused words that name commands and files: # 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 +127,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 +140,99 @@ 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 `, true) } - // An address keeps 932250, which refuses a listing of files. - for _, f := range ghostAddressFields { + // 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) { wantGhostField(t, f, "ls -la /root", true) } } +func TestGhostTwoCommandNamesPass(t *testing.T) { + t.Parallel() + + // The site's title, saved through settings. + siteTitle := ghostField{ + http.MethodPut, + ghostSettings, + `{"settings":[{"key":"title","value":"VALUE"}]}`, + } + wantGhostField(t, siteTitle, ghostCommandNames, false) + + // Values that join two command names, as a person types them. + for _, typed := range []string{ + ghostCommandNames, "python3 & powershell", "powershell & ls -la", + "ls -la & python3", + } { + for _, f := range slices.Concat(ghostTextFields, ghostCodeFields) { + wantGhostField(t, f, typed, false) + } + + wantGhostImportLabel(t, ghostImport, typed, false) + + for _, target := range slices.Concat( + ghostAnalyticsFilters, + []string{ghostAccountSearch}, + ) { + wantGhostQuery(t, target+url.QueryEscape(typed), false) + } + + // filter as the admin's post picker writes it, after a person types + // such a title. + filter := "status:published+title:~'" + typed + "'" + wantGhostQuery(t, ghostAdminFilter+url.QueryEscape(filter), false) + } +} + +// TestGhostOrdinaryQueriesPass checks the query fields of the analytics +// pages, email_segment, the search of ActivityPub and the check of an +// invitation. +func TestGhostOrdinaryQueriesPass(t *testing.T) { + t.Parallel() + + for _, encoded := range hardValues { + value, _ := url.QueryUnescape(encoded) + text := url.QueryEscape("Notes on " + value + " & tidy files") + + for _, target := range ghostAnalyticsFilters { + wantGhostQuery(t, target+encoded, false) + } + + wantGhostQuery(t, ghostCampaign+text, false) + wantGhostQuery(t, ghostAccountSearch+text, false) + } + + // pathname, the page the analytics show: a post and two tag pages. + for _, path := range []string{ + "/tag/proc/", "/python3-and-powershell/", "/tag/python3/", + } { + wantGhostQuery(t, ghostPathname+url.QueryEscape(path), false) + } + + wantGhostQuery( + t, + ghostCampaign+url.QueryEscape(ghostCommandNames), + false, + ) + wantGhostQuery( + t, + ghostAccountSearch+"%40powershell%40example.com", + false, + ) + + for _, segment := range []string{ + "label:powershell-users", "label:python3", "label:ls-la", + "label:htaccess", "status:free,tier:powershell-club", + } { + wantGhostQuery(t, ghostSegment+url.QueryEscape(segment), false) + } + + for _, address := range ghostAddresses { + wantGhostQuery(t, ghostInvitedEmail+url.QueryEscape(address), false) + } +} + +// wantGhostImportLabel checks that the Core Rule Set, reading bodies, +// refuses label, sent as labels in a CSV import to target, or lets it +// through. +func wantGhostImportLabel( + t *testing.T, target, label string, refused bool, +) { + t.Helper() + + r := request{http.MethodPost, target, []string{multipart}} + body := field("labels", label) + field("mapping[email]", "email") + end + + got, _ := inspectBody(t, readingBodies(t), r, body) + if refused && got.Score < ghostThreshold { + t.Errorf("labels=%q at %s let through: %+v", label, target, got) + } + + if !refused && got.Score >= ghostThreshold { + t.Errorf("labels=%q at %s refused: %+v", label, target, got) + } +} + +func TestGhostImportLabelsPass(t *testing.T) { + t.Parallel() + + for _, encoded := range hardValues { + value, _ := url.QueryUnescape(encoded) + wantGhostImportLabel(t, ghostImport, value+" import", false) + } + + wantGhostImportLabel(t, ghostImport, ghostCommandNames, false) + + for _, attack := range ghostAttacks { + wantGhostImportLabel(t, ghostImport, attack, true) + } + + // labels keeps its rules on other paths, and when the query has a field + // of the same name. + wantGhostImportLabel(t, ghostMembers, "python3 import", true) + wantGhostImportLabel(t, ghostImport+"?labels=x", "python3 import", true) +} + func TestGhostExclusionsAreNarrow(t *testing.T) { t.Parallel() @@ -502,7 +847,6 @@ func TestGhostExclusionsAreNarrow(t *testing.T) { `{"settings":[{"value":""}]}`, }, {"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`}, - {"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`}, {ghostPosts, email}, {ghostMembers, email}, {ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`}, @@ -560,6 +904,7 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) { script = `` scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}` scriptInSettings = `{"settings":[{"value":"` + script + `"}]}` + scriptInTag = `{"tags":[{"codeinjection_head":"` + script + `"}]}` ) for _, refused := range []struct{ target, body string }{ @@ -569,6 +914,8 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) { {"/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}} @@ -590,7 +937,8 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) { // The query fields keep their rules off Ghost's paths, and in other // query fields there. search skips its rules only on the Admin API, and - // url only on the editor's lookup of embeds. + // url only on the editor's lookup of embeds. pathname, email_segment, + // query and email skip theirs only on their own paths. for _, target := range []string{ "/api/posts/?filter=powershell", "/ghost/posts/?filter=powershell", @@ -599,11 +947,79 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) { "/members/api/member/?search=python3", "/ghost/api/admin/posts/?url=powershell", "/oembed/?url=powershell", + "/ghost/api/admin/posts/?pathname=%2Fpowershell%2F", + "/ghost/api/admin/stats/top-content/?order=powershell", + "/ghost/api/admin/pages/?email_segment=label%3Apowershell-users", + "/ghost/api/admin/posts/?query=python3", + "/.ghost/activitypub/v1/actions/note?query=python3", + "/ghost/api/admin/invites/?email=python3.dev%40example.com", } { wantGhostQuery(t, target, true) } } +// TestGhostStaffCommentAndActivityPubExclusionsAreNarrow checks the body +// fields that 900370 to 900382 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":"

Notes on .htaccess

"}]}` + 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"}` + actorURI = `{"actorUri":"https://example.com/users/powershell"}` + targetHandle = `{"targetHandle":"@powershell@example.com"}` + domain = `{"domain":"python3.example.com"}` + tagCode = `{"tags":[{"codeinjection_head":""}]}` + tagURL = `{"tags":[{"canonical_url":"https://example.com/tag/powershell/"}]}` + ) + + for _, refused := range []struct{ target, body string }{ + {"/api/users/1/", bio}, + {ghostMember, bio}, + {ghostUser + "?json.users.0.bio=x", bio}, + {ghostPosts, comment}, + {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}, + {ghostMigration, actorURI}, + {ghostAliases + "?json.actorUri=x", actorURI}, + {ghostAliases, targetHandle}, + {ghostMigration + "?json.targetHandle=x", targetHandle}, + {ghostAccount, domain}, + {ghostDomain + "?json.domain=x", domain}, + {ghostPosts, tagCode}, + {ghostTags + "?json.tags.0.codeinjection_head=x", tagCode}, + {ghostPosts, tagURL}, + {ghostTags + "?json.tags.0.canonical_url=x", tagURL}, + } { + r := request{http.MethodPut, refused.target, []string{jsonBody}} + + got, _ := inspectBody(t, readingBodies(t), r, refused.body) + if got.Score < ghostThreshold { + t.Errorf("PUT %s %s let through: %+v", refused.target, + refused.body, got) + } + } +} + func TestGhostPasswordsPass(t *testing.T) { t.Parallel() diff --git a/internal/coreruleset/testdata/ghost-requests.txt b/internal/coreruleset/testdata/ghost-requests.txt index e34dda7..f529b5f 100644 --- a/internal/coreruleset/testdata/ghost-requests.txt +++ b/internal/coreruleset/testdata/ghost-requests.txt @@ -762,3 +762,33 @@ {"method": "POST", "target": "/ghost/api/admin/posts/", "headers": ["Host: example.com", "User-Agent: curl/8.11.1", "Accept: */*", "Authorization: Ghost eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjZhZjQ1Y2VjZDI4MWIwMDAwMWE1OTk5YiJ9.eyJpYXQiOjE3OTQ0MDA2OTQsImV4cCI6MTc5NDQwMDk5NCwiYXVkIjoiL2FkbWluLyJ9.ayqaBc1Hg4fbMZj_XbvUGlhloCnW_D5KDMN8A6uM68M", "Accept-Version: v6.76", "Content-Type: application/json", "Content-Length: 465"], "body": "{\"posts\": [{\"title\": \"Older editor format: python3 notes\", \"mobiledoc\": \"{\\\"version\\\": \\\"0.3.1\\\", \\\"atoms\\\": [], \\\"markups\\\": [[\\\"code\\\"]], \\\"cards\\\": [[\\\"code\\\", {\\\"code\\\": \\\"ls -la /var/www\\\\npython3 tidy.py .htaccess\\\", \\\"language\\\": \\\"bash\\\"}]], \\\"sections\\\": [[1, \\\"h2\\\", [[0, [], 0, \\\"Mobiledoc post about powershell\\\"]]], [1, \\\"p\\\", [[0, [], 0, \\\"Run \\\"], [0, [0], 1, \\\"ls -la\\\"], [0, [], 0, \\\" and keep .htaccess tidy.\\\"]]], [10, 0]]}\", \"status\": \"draft\"}]}"} {"method": "POST", "target": "/ghost/api/admin/snippets/?formats=mobiledoc%2Clexical", "headers": ["Host: example.com", "Content-Length: 519", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A8kdYjooVavGjq0rNroahDCvgTG3XukhZ.ioil5%2Fg42yqQcdl%2B%2FlhvXc4HoOV6hGDetkdr5aPXvuo"], "body": "{\"snippets\":[{\"name\":\"python3 & .htaccess tip\",\"mobiledoc\":\"{}\",\"lexical\": \"{\\\"root\\\": {\\\"children\\\": [{\\\"children\\\": [{\\\"detail\\\": 0, \\\"format\\\": 0, \\\"mode\\\": \\\"normal\\\", \\\"style\\\": \\\"\\\", \\\"text\\\": \\\"Run ls -la, then python3 tidy.py .htaccess; on Windows use powershell.\\\", \\\"type\\\": \\\"extended-text\\\", \\\"version\\\": 1}], \\\"direction\\\": \\\"ltr\\\", \\\"format\\\": \\\"\\\", \\\"indent\\\": 0, \\\"type\\\": \\\"paragraph\\\", \\\"version\\\": 1}], \\\"direction\\\": \\\"ltr\\\", \\\"format\\\": \\\"\\\", \\\"indent\\\": 0, \\\"type\\\": \\\"root\\\", \\\"version\\\": 1}}\"}]}"} {"method": "POST", "target": "/ghost/api/admin/themes/upload/", "headers": ["Host: example.com", "Content-Length: 2138", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryzhPTWHSPscackPlN", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A8kdYjooVavGjq0rNroahDCvgTG3XukhZ.ioil5%2Fg42yqQcdl%2B%2FlhvXc4HoOV6hGDetkdr5aPXvuo"], "body": "------WebKitFormBoundaryzhPTWHSPscackPlN\r\nContent-Disposition: form-data; name=\"file\"; filename=\"notes-theme.zip\"\r\nContent-Type: application/zip\r\n\r\n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\r\n------WebKitFormBoundaryzhPTWHSPscackPlN--\r\n"} +# The requests below were sent to Ghost by a script with the admin's or a +# member's headers. They hold words that name commands and files in more fields. +{"method": "POST", "target": "/ghost/api/admin/authentication/setup/", "headers": ["Host: example.com", "Content-Length: 158", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"setup\":[{\"blogTitle\":\"Notes on python3, powershell & .htaccess\",\"name\":\"Alice ls -la Example\",\"email\":\"alice@example.com\",\"password\":\"Hv4-mQz8-Lr2x-Tp9w\"}]}"} +{"method": "POST", "target": "/ghost/api/admin/session/", "headers": ["Host: example.com", "Content-Length: 64", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"username\":\"alice@example.com\",\"password\":\"Hv4-mQz8-Lr2x-Tp9w\"}"} +{"method": "PUT", "target": "/ghost/api/admin/users/6af54436a509340001efcee2/?include=roles", "headers": ["Host: example.com", "Content-Length: 1256", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"users\":[{\"id\":\"6af54436a509340001efcee2\",\"name\":\"Alice python3 & powershell\",\"slug\":\"alice-python3-powershell\",\"email\":\"alice@example.com\",\"profile_image\":null,\"cover_image\":null,\"bio\":\"I write about ls -la, python3, powershell and .htaccess files.\",\"website\":\"https://example.com/python3/powershell\",\"location\":\"Berlin & the python3 meetup\",\"facebook\":null,\"twitter\":null,\"threads\":null,\"bluesky\":null,\"mastodon\":null,\"tiktok\":null,\"youtube\":null,\"instagram\":null,\"linkedin\":null,\"accessibility\":\"{\\\"nightShift\\\":\\\"system\\\"}\",\"status\":\"active\",\"meta_title\":null,\"meta_description\":null,\"tour\":null,\"last_seen\":\"2026-11-12T04:44:42.000Z\",\"comment_notifications\":true,\"free_member_signup_notification\":true,\"paid_subscription_started_notification\":true,\"paid_subscription_canceled_notification\":false,\"mention_notifications\":true,\"recommendation_notifications\":true,\"milestone_notifications\":true,\"donation_notifications\":true,\"gift_subscription_notifications\":true,\"created_at\":\"2026-11-12T04:44:38.000Z\",\"updated_at\":\"2026-11-12T04:44:42.000Z\",\"roles\":[{\"id\":\"6af54436a509340001efcee7\",\"name\":\"Owner\",\"description\":\"Blog Owner\",\"created_at\":\"2026-11-12T04:44:38.000Z\",\"updated_at\":\"2026-11-12T04:44:38.000Z\"}],\"url\":\"http://example.com/author/alice/\"}]}"} +{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 130", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"erin.htaccess@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"} +{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"python3.dev@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"} +{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 132", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"dave.powershell@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"} +{"method": "GET", "target": "/ghost/api/admin/authentication/invitation/?email=erin.htaccess%40example.com", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": ""} +{"method": "POST", "target": "/ghost/api/admin/authentication/invitation/", "headers": ["Host: example.com", "Content-Length: 243", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"invitation\":[{\"token\":\"MTc5NTA2MzQ4MjU5MnxlcmluLmh0YWNjZXNzQGV4YW1wbGUuY29tfDUxbkp4aWJ4SzA4NzN0eUtIQWl5YzhyWFhKTjA0aWJ1ZDExendTVHQrSU09\",\"name\":\"Erin python3 & .htaccess\",\"password\":\"Rk7-Wq2z-Np5c-Lx8v\",\"email\":\"erin.htaccess@example.com\"}]}"} +{"method": "PUT", "target": "/ghost/api/admin/tiers/6af54437a509340001efcef1/", "headers": ["Host: example.com", "Content-Length: 474", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"tiers\":[{\"id\":\"6af54437a509340001efcef1\",\"name\":\"python3 & powershell club\",\"description\":\"Weekly ls -la and .htaccess tips\",\"slug\":\"default-product\",\"active\":true,\"type\":\"paid\",\"welcome_page_url\":null,\"created_at\":\"2026-11-12T04:44:39.000Z\",\"updated_at\":\"2026-11-12T04:44:39.000Z\",\"visibility\":\"public\",\"benefits\":[\"python3 scripts\",\"powershell recipes\",\"ls -la cheat sheet\",\".htaccess examples\"],\"currency\":\"USD\",\"monthly_price\":500,\"yearly_price\":5000,\"trial_days\":0}]}"} +{"method": "POST", "target": "/ghost/api/admin/offers/", "headers": ["Host: example.com", "Content-Length: 391", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"offers\":[{\"name\":\"python3 launch\",\"code\":\"python3-launch\",\"display_title\":\"powershell & python3 deal\",\"display_description\":\"Half off for ls -la and .htaccess fans\",\"type\":\"percent\",\"cadence\":\"month\",\"amount\":50,\"duration\":\"once\",\"duration_in_months\":null,\"currency_restriction\":false,\"currency\":null,\"status\":\"active\",\"redemption_type\":\"signup\",\"tier\":{\"id\":\"6af54437a509340001efcef1\"}}]}"} +{"method": "POST", "target": "/ghost/api/admin/recommendations/", "headers": ["Host: example.com", "Content-Length: 288", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"recommendations\":[{\"title\":\"python3 & powershell weekly\",\"description\":\"Tips on ls -la and .htaccess files\",\"excerpt\":\"A newsletter about python3, powershell, ls -la and .htaccess.\",\"featured_image\":null,\"favicon\":null,\"url\":\"https://example.com/python3/\",\"one_click_subscribe\":false}]}"} +{"method": "POST", "target": "/ghost/api/admin/integrations/?include=api_keys%2Cwebhooks", "headers": ["Host: example.com", "Content-Length: 55", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"integrations\":[{\"name\":\"python3 & powershell sync\"}]}"} +{"method": "PUT", "target": "/ghost/api/admin/integrations/6af5443aa509340001efcfc6/?include=api_keys%2Cwebhooks", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"integrations\":[{\"name\":\"python3 & powershell sync\",\"description\":\"Runs ls -la and tidies .htaccess files\",\"icon_image\":null}]}"} +{"method": "POST", "target": "/ghost/api/admin/webhooks/", "headers": ["Host: example.com", "Content-Length: 202", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"webhooks\":[{\"event\":\"post.published\",\"target_url\":\"https://example.com/hooks/python3\",\"name\":\"python3 & powershell hook\",\"secret\":null,\"api_version\":\"v6\",\"integration_id\":\"6af5443aa509340001efcfc6\"}]}"} +{"method": "POST", "target": "/ghost/api/admin/tags/?include=count.posts", "headers": ["Host: example.com", "Content-Length: 702", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3AWHEHmHzgM_62oH0uUMxh6TgpQCaauWmB.h%2FuJNd7nfwPSaFGmMarZNVguG8U1ZIIJsaLFE4%2BqXMA"], "body": "{\"tags\":[{\"name\":\"Shell\",\"slug\":\"shell\",\"description\":null,\"feature_image\":null,\"meta_title\":\"python3 & powershell tips\",\"meta_description\":\"Run ls -la and tidy .htaccess files with python3.\",\"canonical_url\":\"https://example.com/tag/powershell/\",\"twitter_image\":null,\"twitter_title\":\"powershell & python3\",\"twitter_description\":\"ls -la, .htaccess and more\",\"og_image\":null,\"og_title\":\"python3 & powershell\",\"og_description\":\"ls -la and .htaccess tips\",\"codeinjection_head\":\"\",\"codeinjection_foot\":\"\",\"accent_color\":null,\"visibility\":\"public\"}]}"} +{"method": "PUT", "target": "/ghost/api/admin/posts/6af5443ba509340001efcfd1/?formats=mobiledoc%2Clexical", "headers": ["Host: example.com", "Content-Length: 315", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"posts\":[{\"updated_at\":\"2026-11-12T04:44:43.000Z\",\"title\":\"Shell notes\",\"feature_image\":\"https://images.example.com/terminal.png\",\"feature_image_alt\":\"A terminal running ls -la on .htaccess with python3\",\"feature_image_caption\":\"python3 & powershell, photo by Erin\"}]}"} +{"method": "POST", "target": "/ghost/api/admin/members/upload/", "headers": ["Host: example.com", "Content-Length: 990", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: multipart/form-data; boundary=----WebKitFormBoundaryq3Lm8vXc2TzR7wKd", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"membersfile\"; filename=\"members.csv\"\r\nContent-Type: text/csv\r\n\r\nemail,name,note,labels\r\npython3.dev@example.com,Grace python3,Asked about ls -la,powershell\r\nerin.htaccess@example.com,Erin,Tidies .htaccess files,python3\r\n\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"labels\"\r\n\r\npython3 import\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"labels\"\r\n\r\npowershell & .htaccess\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[email]\"\r\n\r\nemail\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[name]\"\r\n\r\nname\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[note]\"\r\n\r\nnote\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[labels]\"\r\n\r\nlabels\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd--\r\n"} +{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&pathname=%2Fpython3-and-powershell%2F", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""} +{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&source=powershell.example.com", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""} +{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&utm_campaign=powershell+%26+python3", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""} +{"method": "PUT", "target": "/ghost/api/admin/posts/6af5443ba509340001efcfd1/?formats=mobiledoc%2Clexical&newsletter=default-newsletter&email_segment=label%3Apowershell-users", "headers": ["Host: example.com", "Content-Length: 74", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"posts\":[{\"updated_at\":\"2026-11-12T04:44:43.000Z\",\"status\":\"published\"}]}"} +{"method": "GET", "target": "/ghost/api/admin/posts/?filter=status%3Apublished%2Btitle%3A%7E%27powershell+%26+python3%27&limit=25&fields=id%2Ctitle&order=published_at+DESC", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""} +{"method": "GET", "target": "/ghost/api/admin/posts/?filter=status%3Apublished%2Btitle%3A%7E%27ls+-la+%26+.htaccess%27&limit=25&fields=id%2Ctitle&order=published_at+DESC", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""} +{"method": "POST", "target": "/members/api/comments/", "headers": ["Host: example.com", "Content-Length: 161", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/json", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": "{\"comments\":[{\"post_id\":\"6af5443ba509340001efcfd1\",\"status\":\"published\",\"html\":\"

Thanks! I ran ls -la and fixed my .htaccess with python3.

\"}]}"} +{"method": "PUT", "target": "/members/api/comments/6af5443ba509340001efd00b/", "headers": ["Host: example.com", "Content-Length: 140", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/json", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": "{\"comments\":[{\"id\":\"6af5443ba509340001efd00b\",\"html\":\"

Thanks! I ran ls -la, then powershell, and fixed my .htaccess with python3.

\"}]}"} +{"method": "POST", "target": "/members/api/comments/", "headers": ["Host: example.com", "Content-Length: 220", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/json", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": "{\"comments\":[{\"post_id\":\"6af5443ba509340001efcfd1\",\"parent_id\":\"6af5443ba509340001efd00b\",\"in_reply_to_id\":\"6af5443ba509340001efd00b\",\"status\":\"published\",\"html\":\"

Same here: python3 & powershell both work.

\"}]}"} +{"method": "GET", "target": "/members/api/comments/post/6af5443ba509340001efcfd1/?limit=20&filter=created_at%3A%3C%3D2026-11-12T04%3A44%3A43.000Z&page=2&order=created_at+desc", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": ""} +{"method": "GET", "target": "/members/api/comments/6af5443ba509340001efd00b/replies/?limit=5&filter=id%3A%3E%276af5443ba509340001efd010%27", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": ""}