Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 36c4f91583 Rename the settings whose names do not say what they control (closes #234)
check / check (push) Failing after 1m54s
Applies the setting renames of the naming audit under the SWW_WAF_ and
SWW_RPROXY_ prefixes, with sneak's names for the IP reputation cache
time and the two time limits, which now take milliseconds. The rproxy
log field default_disposition becomes waf_backend_no_answer, and
config.Config and config.FromEnvironment become config.WAF and
config.WAFFromEnvironment. Go fields, test names and prose follow. No
aliases; nothing reads the old names.

Model: opus-5-5
2026-11-12 05:53:58 +00:00
22 changed files with 96 additions and 171 deletions

No files matched your search

+6 -11
View File
@@ -1782,10 +1782,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 `email` when creating the first staff user, asking for a password reset or
saving a profile. 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: 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
@@ -1826,7 +1822,7 @@ fields, on the Admin API:
(941170 and 941210). Path traversal and SQL injection are still refused in every (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. 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 `; 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`. 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 That name holds `.profile`, a file on the list of system files, so the name
@@ -2616,14 +2612,13 @@ A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
that the app never sees that prefix. that the app never sees that prefix.
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is `rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
below `200`. The visitor gets `502` from `rproxy`, whatever below `200`. The visitor gets `502` from `rproxy`, never the default. `rproxy`
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` says. `rproxy` writes a process writes a process warning, its log line has no `disposition`, and it sends no
warning, its log line has no `disposition`, and it sends no report. report.
A visitor that goes away while `waf` is being asked is no failure of `waf`. A visitor that goes away while `waf` is being asked is no failure of `waf`.
`rproxy` closes the connection with no warning and no report, and `rproxy` closes the connection, with no warning, no default and no report. Its
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has log line has status `0` and no `disposition`.
status `0` and no `disposition`.
### Passing a request to the app ### Passing a request to the app
+2 -2
View File
@@ -304,8 +304,8 @@ the request first.
A duration. A duration.
- `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends - `SWW_WAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
within which breaking a limit again lengthens the next ban. A duration. within which breaking a limit again lengthens the next ban. A duration.
- `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE` (default `7d`): a ban for a broken limit - `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE` (default `7d`): the longest ban for a
that would be longer than this is permanent instead. A duration. broken limit; one that would be longer is permanent. A duration.
- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign - `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
of attack. A duration. of attack. A duration.
- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client - `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
+4 -4
View File
@@ -1,6 +1,6 @@
// Package bans is the ban ledger: the bans sww makes on the // Package bans is the ban ledger: the bans sww makes on the
// netblocks of clients that break a rate limit, a byte limit or the refusal // netblocks of clients that break a rate limit, a byte limit or the error
// limit, show a clear sign of attack or are listed by the CrowdSec // burst, show a clear sign of attack or are listed by the CrowdSec
// decision list, and // decision list, and
// those an admin makes, with their notes, as the "Bans" section of SPEC.md // those an admin makes, with their notes, as the "Bans" section of SPEC.md
// describes. The bans are kept in memory, and written to bans.json and // describes. The bans are kept in memory, and written to bans.json and
@@ -49,8 +49,8 @@ type Params struct {
// CrowdSec's decision, a broken limit counts as a repeat, which bans for // CrowdSec's decision, a broken limit counts as a repeat, which bans for
// repeatFactor times as long as that ban. // repeatFactor times as long as that ban.
LimitBanRepeatWindow time.Duration LimitBanRepeatWindow time.Duration
// A ban for a broken limit that would be longer than // LimitBanPermanentAbove is the longest ban for a broken limit; one that would
// LimitBanPermanentAbove is permanent instead. // be longer is permanent instead.
LimitBanPermanentAbove time.Duration LimitBanPermanentAbove time.Duration
// AttackBanDuration is how long a first ban for a clear sign of attack // AttackBanDuration is how long a first ban for a clear sign of attack
// lasts. // lasts.
+1 -28
View File
@@ -16,7 +16,6 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -37,9 +36,7 @@ const (
var ( var (
errNotSize = errors.New( errNotSize = errors.New(
"is not a size such as 512K, 100M or 5G, or off") "is not a size such as 512K, 100M or 5G, or off")
errNotPositive = errors.New("must be more than zero, or off") errNotPositive = errors.New("must be more than zero, or off")
errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000")
errEmptyItem = errors.New("has an empty item in its list") errEmptyItem = errors.New("has an empty item in its list")
errNotNetblock = errors.New( errNotNetblock = errors.New(
"is not a netblock such as 10.0.0.0/8, or an address") "is not a netblock such as 10.0.0.0/8, or an address")
@@ -166,19 +163,6 @@ func (e *environment) size(name, defaultValue string) int64 {
return size return size
} }
// milliseconds reads a setting that is a whole number of milliseconds above
// zero.
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
value := e.value(name, defaultValue)
duration, valid := parseMilliseconds(value)
if !valid {
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
}
return duration
}
// parseSize reads a number of bytes with an optional K, M or G suffix, in // parseSize reads a number of bytes with an optional K, M or G suffix, in
// powers of 1024 (1K is 1024 bytes), or off. // powers of 1024 (1K is 1024 bytes), or off.
func parseSize(value string) (int64, error) { func parseSize(value string) (int64, error) {
@@ -215,17 +199,6 @@ func splitUnit(value string) (string, int64) {
} }
} }
// parseMilliseconds reads a whole number of milliseconds above zero, and
// reports whether it is one.
func parseMilliseconds(value string) (time.Duration, bool) {
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
return 0, false
}
return time.Duration(n) * time.Millisecond, true
}
// parseList splits a comma-separated list and trims the spaces around // parseList splits a comma-separated list and trims the spaces around
// each item. An empty value is an empty list. // each item. An empty value is an empty list.
func parseList(value string) ([]string, error) { func parseList(value string) ([]string, error) {
+28 -1
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"net/netip" "net/netip"
"net/url" "net/url"
"strconv" "strconv"
@@ -63,7 +64,9 @@ var (
"such as http://127.0.0.1:8081") "such as http://127.0.0.1:8081")
errNeedsWAFBackend = errors.New( errNeedsWAFBackend = errors.New(
"is required: it is waf's URL, such as http://waf:8080") "is required: it is waf's URL, such as http://waf:8080")
errNotDisposition = errors.New("is not allow or deny") errNotDisposition = errors.New("is not allow or deny")
errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000")
errNotMillisecondsOrOff = errors.New( errNotMillisecondsOrOff = errors.New(
"is not a whole number of milliseconds above zero, such as 60000, or off", "is not a whole number of milliseconds above zero, such as 60000, or off",
) )
@@ -175,6 +178,19 @@ func (e *environment) disposition(name, defaultValue string) string {
return value return value
} }
// milliseconds reads a setting that is a whole number of milliseconds above
// zero.
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
value := e.value(name, defaultValue)
duration, valid := parseMilliseconds(value)
if !valid {
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
}
return duration
}
// millisecondsOrOff reads a setting that is a whole number of milliseconds // millisecondsOrOff reads a setting that is a whole number of milliseconds
// above zero, or off, which is 0. // above zero, or off, which is 0.
func (e *environment) millisecondsOrOff( func (e *environment) millisecondsOrOff(
@@ -193,6 +209,17 @@ func (e *environment) millisecondsOrOff(
return duration return duration
} }
// parseMilliseconds reads a whole number of milliseconds above zero, and
// reports whether it is one.
func parseMilliseconds(value string) (time.Duration, bool) {
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
return 0, false
}
return time.Duration(n) * time.Millisecond, true
}
// parseUpstreamURL reads the app's URL: http or https, a host and an // parseUpstreamURL reads the app's URL: http or https, a host and an
// optional port from 1 to 65535, and nothing else, since the request's // optional port from 1 to 65535, and nothing else, since the request's
// own path and query go to the app unchanged. // own path and query go to the app unchanged.
+2 -3
View File
@@ -71,8 +71,7 @@ type WAF struct {
// most bytes a client's requests may carry in a minute, an hour and a // most bytes a client's requests may carry in a minute, an hour and a
// day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and // day (SWW_WAF_BYTES_LIMIT_PER_MINUTE, SWW_WAF_BYTES_LIMIT_PER_HOUR and
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCounted is which body bytes count // SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCounted is which body bytes count
// toward them and the byte anomaly thresholds (SWW_WAF_BYTES_COUNTED): // toward them (SWW_WAF_BYTES_COUNTED): response, request or both.
// response, request or both.
BytesLimitPerMinute int64 BytesLimitPerMinute int64
BytesLimitPerHour int64 BytesLimitPerHour int64
BytesLimitPerDay int64 BytesLimitPerDay int64
@@ -847,7 +846,7 @@ func (e *environment) count(name, defaultValue string) int64 {
} }
// bytesCounted reads the setting that is which body bytes count toward the // bytesCounted reads the setting that is which body bytes count toward the
// byte limits and the byte anomaly thresholds: response, request or both. // byte limits: response, request or both.
func (e *environment) bytesCounted(name, defaultValue string) string { func (e *environment) bytesCounted(name, defaultValue string) string {
value := e.value(name, defaultValue) value := e.value(name, defaultValue)
if value != "response" && value != "request" && value != "both" { if value != "response" && value != "request" && value != "both" {
+1 -1
View File
@@ -2942,7 +2942,7 @@ func TestCorazaWritesNoFile(t *testing.T) {
wantBody(t, crs, post(multipart), body, matched(942100), body) wantBody(t, crs, post(multipart), body, matched(942100), body)
} }
func TestBodyInspectionSizeOf1GLoads(t *testing.T) { func TestBodyLimitOf1GLoads(t *testing.T) {
t.Parallel() t.Parallel()
_, err := coreruleset.New( _, err := coreruleset.New(
-17
View File
@@ -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" \ 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}/$" \
+5 -32
View File
@@ -143,16 +143,6 @@ var ghostAddressFields = []ghostField{
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`}, {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 // ghostPostFields hold a post or a page, skip the rules of 900355, and take a
// <script> element by 900366. // <script> element by 900366.
// //
@@ -230,16 +220,13 @@ var ghostQueryFields = []string{
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed, ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
} }
// ghostURLs are addresses 930120 and 932120 refused in url and in a canonical // ghostURLs are addresses 930120 and 932120 refused in url.
// URL.
// //
//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.
@@ -340,10 +327,6 @@ 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 ghostCanonicalURLFields {
wantGhostField(t, f, address, false)
}
} }
for _, address := range ghostAddresses { for _, address := range ghostAddresses {
@@ -412,7 +395,7 @@ 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,
ghostCanonicalURLFields, ghostArticleFields, ghostCodeFields) { ghostArticleFields, ghostCodeFields) {
wantGhostField(t, f, attack, true) 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 // 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,
ghostCanonicalURLFields, ghostSnippetAndNewsletterFooterFields) { ghostSnippetAndNewsletterFooterFields) {
wantGhostField(t, f, scriptTag, true) 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 // A command after a separator stays refused wherever its rules do not
// misfire: in the query fields, the plain text, the addresses and the // misfire: in the query fields, the plain text and the addresses.
// canonical URLs. for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) {
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
ghostCanonicalURLFields) {
wantGhostField(t, f, commandAfterASeparator, true) wantGhostField(t, f, commandAfterASeparator, true)
} }
@@ -481,8 +462,6 @@ 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 }{
@@ -519,12 +498,6 @@ 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}]}`},
-2
View File
@@ -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": "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": ""}
+2 -2
View File
@@ -185,8 +185,8 @@ func New(limits Limits, maxClients int) *Limiter {
} }
} }
// Hit is a request that takes a client over a rate limit or the refusal // Hit is a request that takes a client over a rate limit or the error
// limit, or whose bytes take it over a byte limit. // burst, or whose bytes take it over a byte limit.
type Hit struct { type Hit struct {
// Kind is KindRequests for a rate limit, KindBytes for a byte limit, // Kind is KindRequests for a rate limit, KindBytes for a byte limit,
// KindRefusals for the refusal limit. // KindRefusals for the refusal limit.
+1 -3
View File
@@ -351,9 +351,7 @@ func TestResetSetsTheBytesBackToZero(t *testing.T) {
wantBytesCount(t, limiter, client, start, 1000, "") wantBytesCount(t, limiter, client, start, 1000, "")
} }
func TestRefusalsOverTheThresholdInAMinuteBreakTheRefusalLimit( func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
limiter := ratelimit.New(ratelimit.Limits{}, tableSize) limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
+4 -9
View File
@@ -93,15 +93,10 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
}, },
) )
// The visitor sends no Connection: close, so Go's server reads the rest got := visit(t, newRequest(t, http.MethodPost, addr, "/",
// of the body before it closes the connection. Closed with the rest bytes.NewReader(make([]byte, tc.size))))
// unread, the connection is reset while the visitor still sends. if got.status != http.StatusRequestEntityTooLarge {
conn := dial(t, addr) t.Errorf("answered %+v, want 413", got)
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 {
+10 -23
View File
@@ -956,8 +956,7 @@ func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
} }
} }
// noAnswers are the ways waf can leave the decision to // noAnswers are the ways waf can leave the decision to the default.
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION.
func noAnswers() map[string]func(t *testing.T) string { func noAnswers() map[string]func(t *testing.T) string {
return map[string]func(t *testing.T) string{ return map[string]func(t *testing.T) string{
"no connection": func(*testing.T) string { return nowhere }, "no connection": func(*testing.T) string { return nowhere },
@@ -1009,8 +1008,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK}, {"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
} { } {
for _, setting := range []string{dispositionAllow, dispositionDeny} { for _, setting := range []string{dispositionAllow, dispositionDeny} {
subtest := tc.name + " with " + noAnswerSetting + "=" + setting t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
t.Run(subtest, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startWAF( standIn := startWAF(
@@ -1051,7 +1049,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
if line["status"] != float64(tc.status) || if line["status"] != float64(tc.status) ||
line["waf_backend_no_answer"] != nil { line["waf_backend_no_answer"] != nil {
t.Errorf( t.Errorf(
"line %v, want status %d and no waf_backend_no_answer", "line %v, want status %d and no default",
line, line,
tc.status, tc.status,
) )
@@ -1204,8 +1202,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
"a 101 that holds its connection open": answeringWith(switchAndHold), "a 101 that holds its connection open": answeringWith(switchAndHold),
} { } {
for _, setting := range []string{dispositionAllow, dispositionDeny} { for _, setting := range []string{dispositionAllow, dispositionDeny} {
subtest := name + " with " + noAnswerSetting + "=" + setting t.Run(name+" with the default "+setting, func(t *testing.T) {
t.Run(subtest, func(t *testing.T) {
t.Parallel() t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
@@ -1256,9 +1253,7 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
standIn.wantNoReport(t) standIn.wantNoReport(t)
} }
func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides( func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
for name, backend := range noAnswers() { for name, backend := range noAnswers() {
@@ -1280,10 +1275,7 @@ func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
line := out.requestLine(t) line := out.requestLine(t)
if line["disposition"] != dispositionAllow || if line["disposition"] != dispositionAllow ||
line["waf_backend_no_answer"] != true { line["waf_backend_no_answer"] != true {
t.Errorf( t.Errorf("line %v, want allow by default", line)
"line %v, want allow and waf_backend_no_answer",
line,
)
} }
}) })
@@ -1311,20 +1303,17 @@ func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
line := out.requestLine(t) line := out.requestLine(t)
if line["disposition"] != dispositionDeny || if line["disposition"] != dispositionDeny ||
line["waf_backend_no_answer"] != true { line["waf_backend_no_answer"] != true {
t.Errorf( t.Errorf("line %v, want deny by default", line)
"line %v, want deny and waf_backend_no_answer",
line,
)
} }
}) })
} }
} }
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) { func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
t.Parallel() t.Parallel()
for _, disposition := range []string{dispositionAllow, dispositionDeny} { for _, disposition := range []string{dispositionAllow, dispositionDeny} {
t.Run(noAnswerSetting+"="+disposition, func(t *testing.T) { t.Run("with the default "+disposition, func(t *testing.T) {
t.Parallel() t.Parallel()
standIn := startWAF( standIn := startWAF(
@@ -1367,9 +1356,7 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) {
} }
} }
func TestRProxyWarnsWhenTheWAFBackendNoAnswerDispositionDecides( func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
app := startApp(t, greet) app := startApp(t, greet)
+1 -2
View File
@@ -241,8 +241,7 @@ func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
forwardTo("") forwardTo("")
stop() stop()
// With waf down, the site answers, and rproxy says // With waf down, the site answers, and rproxy says the default did it.
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION did it.
wantStatus(t, site+"/after-stop", placed, http.StatusOK) wantStatus(t, site+"/after-stop", placed, http.StatusOK)
line := out.line(t, "path", "/after-stop") line := out.line(t, "path", "/after-stop")
+1 -1
View File
@@ -602,7 +602,7 @@ func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
) )
} }
func TestRuleFilesDirThatDoesNotExistStopsTheStart(t *testing.T) { func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
dir := filepath.Join(t.TempDir(), "rules.d") dir := filepath.Join(t.TempDir(), "rules.d")
+3 -5
View File
@@ -150,7 +150,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
} }
} }
func TestBytesLimitPercentSettingsTakeThePlaceOfTheOthersForByteLimits( func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -303,7 +303,7 @@ func TestLowestPercentageApplies(t *testing.T) {
} }
} }
func TestUnknownCountryLimitPercentGivesEveryClientWithoutACountryItsPercentage( func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -330,9 +330,7 @@ func TestUnknownCountryLimitPercentGivesEveryClientWithoutACountryItsPercentage(
} }
} }
func TestClientWithoutAnAnswerInTimeHasTheUnknownCountryLimitPercent( func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
t *testing.T,
) {
t.Parallel() t.Parallel()
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
+1 -1
View File
@@ -239,7 +239,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
} }
} }
func TestBytesCountedSaysWhichBytesAreCounted(t *testing.T) { func TestBytesCountSaysWhichBytesCount(t *testing.T) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
+3 -3
View File
@@ -276,7 +276,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
wantCoreRuleSet(t, line, nil) wantCoreRuleSet(t, line, nil)
} }
func TestCoreRuleSetScoreThreshold(t *testing.T) { func TestAnomalyThreshold(t *testing.T) {
t.Parallel() t.Parallel()
// A score under the threshold, or with the threshold off, is logged, // A score under the threshold, or with the threshold off, is logged,
@@ -526,7 +526,7 @@ func TestResponsesAreNotInspected(t *testing.T) {
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward) wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
} }
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit( func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -582,7 +582,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit(
) )
} }
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheRefusalLimit( func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
+17 -17
View File
@@ -48,9 +48,9 @@ func (r refused) send(s *sender, from string) logLine {
return logLine{} return logLine{}
} }
// startForRefusalLimit is startWithClock with testRules, the metrics token and // startForErrorBurst is startWithClock with testRules, the metrics token and
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at threshold, and the settings in env. // SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at threshold, and the settings in env.
func startForRefusalLimit( func startForErrorBurst(
t *testing.T, threshold string, env map[string]string, t *testing.T, threshold string, env map[string]string,
) (*sender, *clock, *waf.Server) { ) (*sender, *clock, *waf.Server) {
t.Helper() t.Helper()
@@ -65,7 +65,7 @@ func startForRefusalLimit(
return startWithClock(t, "", settings) return startWithClock(t, "", settings)
} }
func TestRefusalLimitBreaksAtOneOverTheThreshold(t *testing.T) { func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -90,7 +90,7 @@ func TestRefusalLimitBreaksAtOneOverTheThreshold(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
s, _, _ := startForRefusalLimit(t, "3", nil) s, _, _ := startForErrorBurst(t, "3", nil)
// Three refusals break nothing, and the app's answers between // Three refusals break nothing, and the app's answers between
// them are not counted. // them are not counted.
@@ -109,8 +109,8 @@ func TestRefusalLimitBreaksAtOneOverTheThreshold(t *testing.T) {
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
} }
// The fourth is answered as the others were, breaks the refusal // The fourth is answered as the others were, breaks the error
// limit, and bans the client. // burst, and bans the client.
line := tc.refusals[3].send(s, client) line := tc.refusals[3].send(s, client)
if line.LimitHit != requestlog.LimitHitMinuteRefusals || if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit { line.Offence != requestlog.OffenceLimit {
@@ -130,7 +130,7 @@ func TestRefusalLimitBreaksAtOneOverTheThreshold(t *testing.T) {
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) { func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel() t.Parallel()
s, _, _ := startForRefusalLimit(t, "1", nil) s, _, _ := startForErrorBurst(t, "1", nil)
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}` const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
@@ -146,10 +146,10 @@ func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
`{"window": "minute", "count": 2, "limit": 1}`) `{"window": "minute", "count": 2, "limit": 1}`)
} }
func TestRefusalLimitBanNotesHistoryAndMetrics(t *testing.T) { func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel() t.Parallel()
s, clk, server := startForRefusalLimit(t, "2", nil) s, clk, server := startForErrorBurst(t, "2", nil)
start := clk.Now() start := clk.Now()
blockRule.send(s, client) blockRule.send(s, client)
@@ -221,7 +221,7 @@ func TestRefusalLimitBanNotesHistoryAndMetrics(t *testing.T) {
) )
} }
func TestRefusalLimitIsNotLoweredForAClientWithLowerLimits(t *testing.T) { func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
t.Parallel() t.Parallel()
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
@@ -252,7 +252,7 @@ func TestRefusalLimitIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
} }
} }
func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) { func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
t.Parallel() t.Parallel()
statuses := map[string]int{ statuses := map[string]int{
@@ -293,7 +293,7 @@ func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
wantAlerts(t, queue) wantAlerts(t, queue)
} }
func TestRefusalLimitOffOrAtItsDefault(t *testing.T) { func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -331,7 +331,7 @@ func TestRefusalLimitOffOrAtItsDefault(t *testing.T) {
} }
} }
func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) { func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
@@ -339,7 +339,7 @@ func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
) )
s, _, _ := startForRefusalLimit(t, "1", map[string]string{ s, _, _ := startForErrorBurst(t, "1", map[string]string{
allowNets: allowed, rateLimitExemptNets: exempt, allowNets: allowed, rateLimitExemptNets: exempt,
}) })
@@ -367,10 +367,10 @@ func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned) s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestRefusalLimitBanSetsTheRefusalsBackToZero(t *testing.T) { func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
t.Parallel() t.Parallel()
s, clk, _ := startForRefusalLimit( s, clk, _ := startForErrorBurst(
t, t,
"1", "1",
map[string]string{limitBanDuration: "1s"}, map[string]string{limitBanDuration: "1s"},
@@ -389,7 +389,7 @@ func TestRefusalLimitBanSetsTheRefusalsBackToZero(t *testing.T) {
} }
} }
func TestObserveModeLogsAndAlertsABrokenRefusalLimit(t *testing.T) { func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
t.Parallel() t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{ s, clk, server, queue := startWithAlerts(t, map[string]string{
+2 -2
View File
@@ -432,7 +432,7 @@ const (
noResolver = "127.0.0.1:9" noResolver = "127.0.0.1:9"
) )
func TestEachDNSBLAbuseIPDBActionForAClientADNSBLZoneLists(t *testing.T) { func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
@@ -989,7 +989,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
} }
} }
func TestEachDNSBLAbuseIPDBActionForAClientAbuseIPDBScoresAtOrOverTheMinimum( func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
+2 -2
View File
@@ -811,7 +811,7 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
wantWouldAction(t, line, requestlog.ActionClientDenied) wantWouldAction(t, line, requestlog.ActionClientDenied)
} }
func TestObserveModeRefusalAnsweredAllowCountsForTheRefusalLimit( func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -837,7 +837,7 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheRefusalLimit(
} }
} }
func TestDeniedQuestionCountsForTheRefusalLimit(t *testing.T) { func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
t.Parallel() t.Parallel()
s := startWAF(t, "", map[string]string{ s := startWAF(t, "", map[string]string{