Compare commits

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

Model: opus-5-5
2026-11-12 09:08:49 +00:00
22 changed files with 171 additions and 1341 deletions

No files matched your search

+3 -3
View File
@@ -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, - `nextcloud`, whose fixes let through its sync client, its WebDAV methods,
its searches, its file names, its account names, its comments and its its searches, its file names, its account names, its comments and its
documents; documents;
- `ghost`, whose fixes let through the text its admin and members type, its - `ghost`, whose fixes let through its filters, its searches, its names, its
analytics, its code injection, ActivityPub and a post saved from its email addresses, its code injection, its settings and a post saved from
editor, HTML included. its editor, HTML included.
- A random password, as a password manager makes one, gets through these forms: - A random password, as a password manager makes one, gets through these forms:
- signing in, signing up, and changing or resetting a password, on - signing in, signing up, and changing or resetting a password, on
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud, gitea and Ghost; WordPress, Drupal, MediaWiki, Mastodon, Nextcloud, gitea and Ghost;
+28 -97
View File
@@ -214,9 +214,9 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
are updated, and the alert thresholds are evaluated. are updated, and the alert thresholds are evaluated.
For a request it refused, `waf` adds nothing to the byte limits. It updates For a request it refused, `waf` adds nothing to the byte limits. It updates
the history and the anomaly counters at its decision. A refusal for a rule the history and the anomaly counters at its decision. It counts the refusals
file match, a trap path or a Core Rule Set match also counts toward the the refusal limit counts at the decision too (`refusal_limit`; see "Attack
refusal limit at the decision (`refusal_limit`; see "Attack detection"). detection").
In `SWW_WAF_MODE=observe`, every refusal above is logged as what would have 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 happened, nothing is refused, and `waf` makes no ban of its own. A path under
@@ -883,12 +883,11 @@ one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
which refused `org.apache.commons.logging`. which refused `org.apache.commons.logging`.
A branch, tag or package name can hold a slash, as `feat/docker-support` 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 does. So `q` skips the rule for shell paths (932160) too on these pages,
searches, where it refused `/bin/sh`: where it refused `/bin/sh`:
- a repository's branches, tags and packages pages; - a repository's branches, tags and packages pages;
- an owner's packages page; - 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 - `q` on gitea's other searches, for the same four rules. Words such as
`python3`, `powershell`, `ls -la` and `.htaccess` were refused there. The `python3`, `powershell`, `ls -la` and `.htaccess` were refused there. The
@@ -903,19 +902,12 @@ 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 - the runner lists of the site, a user, an organisation and a
repository; repository;
- the admin lists of users, organisations, repositories, unadopted - 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 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 repository or activate an email send it in the body. gitea serves its
admin pages under `/-/admin/` and Forgejo under `/admin/`. 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: - The path of these requests, which names a file:
- a file's page; - a file's page;
- its raw file; - its raw file;
@@ -1764,87 +1756,35 @@ 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 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 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 under `/ghost/api/admin/`. Themes and Ghost's scripts call the Content API under
`/ghost/api/content/`, and members call `/members/api/`. The admin calls `/ghost/api/content/`, and members call `/members/api/`.
ActivityPub, a service of its own, under `/.ghost/activitypub/`.
Eight rules refused words that name commands and files, such as `python3`, Three query fields skip some rules:
`ls -la`, `.htaccess` and the title `powershell & python3`. They are 930120,
932120, 932125, 932230, 932235, 932250, 932260 and 932380.
These query fields skip some rules: - `filter`, a query in Ghost's own filter language, skips 932120 on the Admin
API and the Content API, so `tag:powershell` passes;
- `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 - `search`, a search for a member, skips 930120, 932120, 932250 and 932260 on
the Admin API; the Admin API;
- `url`, the address the editor looks up for an embed or a bookmark, skips - `url`, the address the editor looks up for an embed or a bookmark, skips
930120 and 932120; 930120 and 932120.
- `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 those eight The fields of JSON bodies where a person types plain text skip 930120, 932120,
rules. They are these fields of posts, pages, tags, labels, members, newsletters 932125, 932230, 932250, 932260 and 932380. They are these fields of posts,
and snippets, on the Admin API: pages, tags, labels, members, newsletters and snippets, on the Admin API:
- `title`, `name` and `slug`; - `title`, `name` and `slug`;
- a post's `tags` and a member's `labels`, by name or slug; - a post's `tags` and a member's `labels`, by name or slug;
- `custom_excerpt`, `description`, `note` and `sender_name`; - `custom_excerpt`, `description`, `note` and `sender_name`;
- the `meta_`, `og_` and `twitter_` titles and descriptions. - the `meta_`, `og_` and `twitter_` titles and descriptions.
These fields on the Admin API skip them too: 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.
- a staff user's `name`, `slug`, `bio`, `location` and `website`; The `canonical_url` of a post or a page skips 930120 and 932120, as `url` does.
- `blogTitle` and `name` when creating the first staff user; A post's skips them under `/ghost/api/admin/posts/`, and a page's under
- `name` when accepting an invitation; `/ghost/api/admin/pages/`.
- `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: The articles carry HTML on purpose. These fields on the Admin API hold them:
@@ -1864,14 +1804,12 @@ The articles skip these rules:
Code injection is the HTML that Ghost adds to the head and the foot of a page. 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: These fields on the Admin API hold it:
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags; - `codeinjection_head` and `codeinjection_foot` of posts and pages;
- `value` of settings, since the site's settings hold its code injection. - `value` of settings, since the site's settings hold its code injection.
Code injection skips these rules: Code injection skips these rules:
- 930120, 932120, 932125, 932230, 932235, 932250, 932260 and 932380, which - 930120, 932120, 932125, 932230, 932250, 932260 and 932380, as plain text does;
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>` - 921130, 941100, 941160 and 941270, which refused `<meta>` and `<link>`
elements. elements.
@@ -1880,22 +1818,15 @@ card can embed a widget and code injection can load analytics. These are the
fields, on the Admin API: fields, on the Admin API:
- `lexical`, `mobiledoc` and `html` of posts and pages; - `lexical`, `mobiledoc` and `html` of posts and pages;
- `codeinjection_head` and `codeinjection_foot` of posts, pages and tags; - `codeinjection_head` and `codeinjection_foot` of posts and pages;
- `value` of settings. - `value` of settings.
`<script>alert(1)</script>` is still refused in snippets and newsletters. `<script>alert(1)</script>` is still refused in snippets and newsletters.
`javascript:alert(1)` is still refused in the articles and code injection `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 (941170 and 941210). Path traversal and SQL injection are still refused in every
field above. Script injection is too, outside the articles and code injection. field above. Script injection is too, outside the articles and code injection.
`; wget http://example.com/x.sh` is still refused in the query fields, the plain
`; wget http://example.com/x.sh` is still refused in these fields: text, the addresses and the canonical URLs.
- 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`. Coraza names a staff user's `profile_image` field `json.users.0.profile_image`.
That name holds `.profile`, a file on the list of system files, so the name That name holds `.profile`, a file on the list of system files, so the name
+5 -4
View File
@@ -65,10 +65,11 @@ func Command(v *viper.Viper) (string, error) {
} }
} }
// environment is where WAFFromEnvironment and RProxyFromEnvironment read the // environment is where WAFFromEnvironment and RProxyFromEnvironment read
// settings: it notes each value for the log, and keeps the first error. It // the settings: it notes each
// reads every setting through v, the viper NewViper returns, and honours // value for the log, and keeps the first error. It reads every setting
// each setting's _FILE form too. // through v, the viper NewViper returns, and honours each setting's _FILE
// form too.
type environment struct { type environment struct {
v *viper.Viper v *viper.Viper
settings []slog.Attr settings []slog.Attr
+11 -9
View File
@@ -27,7 +27,8 @@ import (
"sneak.berlin/go/smallwebwaf/internal/remotelog" "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 { type WAF struct {
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR). // ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
ListenAddr string ListenAddr string
@@ -76,15 +77,16 @@ type WAF struct {
BytesLimitPerHour int64 BytesLimitPerHour int64
BytesLimitPerDay int64 BytesLimitPerDay int64
BytesCounted string BytesCounted string
// LookupSource is where each client's AS number and country are looked // LookupSource is where each client's AS number and country are
// up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere. // looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
// LookupDBPath is the lookup database, the IPinfo Lite file looked up in // LookupDBPath is the lookup database, the IPinfo Lite file looked up
// while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any // in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
// other source. A request waits up to ASNAndCountryLookupTimeout for its // other source. A request waits up to ASNAndCountryLookupTimeout for
// client's first answer from GeoJS while a setting needs it // its client's first answer from GeoJS while a setting needs it
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off. // (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 // AddLookupHeaders is true when the app is passed the client's AS
// and country in headers (SWW_WAF_ADD_LOOKUP_HEADERS). // number and country in headers
// (SWW_WAF_ADD_LOOKUP_HEADERS).
LookupSource string LookupSource string
LookupDBPath string LookupDBPath string
ASNAndCountryLookupTimeout time.Duration ASNAndCountryLookupTimeout time.Duration
+62 -62
View File
@@ -248,8 +248,8 @@ func (e environment) viper() *viper.Viper {
return configtest.Viper(e) return configtest.Viper(e)
} }
// wafFromEnvironment reads waf's settings from env, which must be valid. // fromEnvironment reads the settings from env, which must be valid.
func wafFromEnvironment(t *testing.T, env environment) *config.WAF { func fromEnvironment(t *testing.T, env environment) *config.WAF {
t.Helper() t.Helper()
cfg, err := config.WAFFromEnvironment(env.viper()) cfg, err := config.WAFFromEnvironment(env.viper())
@@ -263,7 +263,7 @@ func wafFromEnvironment(t *testing.T, env environment) *config.WAF {
func TestDefaults(t *testing.T) { func TestDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
wantSettings(t, cfg, config.WAF{ wantSettings(t, cfg, config.WAF{
ListenAddr: ":8080", ListenAddr: ":8080",
@@ -334,7 +334,7 @@ func TestDefaults(t *testing.T) {
func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) { func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
rproxy, err := rproxyConfig(map[string]string{wafBackend: backendURL}) rproxy, err := rproxyConfig(map[string]string{wafBackend: backendURL})
if err != nil { if err != nil {
@@ -359,7 +359,7 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
func TestValuesAsSet(t *testing.T) { func TestValuesAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
listenAddr: "127.0.0.1:9000", listenAddr: "127.0.0.1:9000",
mode: "observe", mode: "observe",
allowNets: "192.0.2.7", allowNets: "192.0.2.7",
@@ -429,7 +429,7 @@ func TestIPv6GroupPrefixMaxTrackedClientsAndLogLevel(t *testing.T) {
48, 500, slog.LevelWarn, 48, 500, slog.LevelWarn,
}, },
} { } {
cfg := wafFromEnvironment(t, tc.env) cfg := fromEnvironment(t, tc.env)
if cfg.IPv6GroupPrefix != tc.prefix || if cfg.IPv6GroupPrefix != tc.prefix ||
cfg.MaxTrackedClients != tc.clients || cfg.MaxTrackedClients != tc.clients ||
cfg.LogLevel != tc.level { cfg.LogLevel != tc.level {
@@ -444,7 +444,7 @@ func TestIPv6GroupPrefixFrom32To128(t *testing.T) {
t.Parallel() t.Parallel()
for _, length := range []int{32, 128} { for _, length := range []int{32, 128} {
cfg := wafFromEnvironment( cfg := fromEnvironment(
t, t,
environment{ipv6GroupPrefix: strconv.Itoa(length)}, environment{ipv6GroupPrefix: strconv.Itoa(length)},
) )
@@ -468,7 +468,7 @@ func TestEachLogLevel(t *testing.T) {
"warn": slog.LevelWarn, "warn": slog.LevelWarn,
"error": slog.LevelError, "error": slog.LevelError,
} { } {
cfg := wafFromEnvironment(t, environment{logLevel: value}) cfg := fromEnvironment(t, environment{logLevel: value})
if cfg.LogLevel != want { if cfg.LogLevel != want {
t.Errorf( t.Errorf(
"%s=%s gave %v, want %v", "%s=%s gave %v, want %v",
@@ -484,7 +484,7 @@ func TestEachLogLevel(t *testing.T) {
func TestByteLimitSettingsAsSet(t *testing.T) { func TestByteLimitSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
bytesLimitPerMinute: "512M", bytesLimitPerMinute: "512M",
bytesLimitPerHour: off, bytesLimitPerHour: off,
bytesLimitPerDay: "100000", bytesLimitPerDay: "100000",
@@ -500,7 +500,7 @@ func TestByteLimitSettingsAsSet(t *testing.T) {
func TestRateLimitExemptPathsAsSet(t *testing.T) { func TestRateLimitExemptPathsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment( cfg := fromEnvironment(
t, t,
environment{rateLimitExemptPaths: "/assets/, /favicon.ico"}, environment{rateLimitExemptPaths: "/assets/, /favicon.ico"},
) )
@@ -542,7 +542,7 @@ func TestTrapPathsAndRefusalsLimitPerMinute(t *testing.T) {
}, },
{environment{refusalsLimitPerMinute: off}, []string{}, 0}, {environment{refusalsLimitPerMinute: off}, []string{}, 0},
} { } {
cfg := wafFromEnvironment(t, tc.env) cfg := fromEnvironment(t, tc.env)
if !slices.Equal(cfg.TrapPaths, tc.paths) || if !slices.Equal(cfg.TrapPaths, tc.paths) ||
cfg.RefusalsLimitPerMinute != tc.limit { cfg.RefusalsLimitPerMinute != tc.limit {
t.Errorf( t.Errorf(
@@ -644,7 +644,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
}, },
}, },
} { } {
cfg := wafFromEnvironment(t, tc.env) cfg := fromEnvironment(t, tc.env)
got := config.WAF{ got := config.WAF{
CoreRuleSetMode: cfg.CoreRuleSetMode, CoreRuleSetMode: cfg.CoreRuleSetMode,
@@ -684,7 +684,7 @@ func TestApps(t *testing.T) {
map[string][]string{"cloud.example.com": {gitea, "wordpress"}}, map[string][]string{"cloud.example.com": {gitea, "wordpress"}},
}, },
} { } {
cfg := wafFromEnvironment(t, tc.env) cfg := fromEnvironment(t, tc.env)
if !reflect.DeepEqual(cfg.Apps, tc.want) { if !reflect.DeepEqual(cfg.Apps, tc.want) {
t.Errorf("%v gave %#v, want %#v", tc.env, 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 var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)). slog.New(slog.NewJSONHandler(&out, nil)).
Info("starting", "settings", wafFromEnvironment(t, tc.env)) Info("starting", "settings", fromEnvironment(t, tc.env))
if !strings.Contains(out.String(), tc.want) { if !strings.Contains(out.String(), tc.want) {
t.Errorf( t.Errorf(
@@ -766,7 +766,7 @@ func TestInvalidAppsStopsTheStart(t *testing.T) {
func TestCoreRuleSetBodyInspectionSizeOf64K(t *testing.T) { func TestCoreRuleSetBodyInspectionSizeOf64K(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment( cfg := fromEnvironment(
t, t,
environment{coreRuleSetBodyInspectionSize: "64K"}, environment{coreRuleSetBodyInspectionSize: "64K"},
) )
@@ -845,7 +845,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) { func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
instanceName: "fsn1app1/blog", instanceName: "fsn1app1/blog",
logRequestHeaders: " Accept , X-Custom", logRequestHeaders: " Accept , X-Custom",
}) })
@@ -863,7 +863,7 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
func TestRemoteLogSettingsDefaults(t *testing.T) { func TestRemoteLogSettingsDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{instanceName: instance}) cfg := fromEnvironment(t, environment{instanceName: instance})
if cfg.LogRemoteURL != nil || cfg.LogRemoteTLSCAs != nil || if cfg.LogRemoteURL != nil || cfg.LogRemoteTLSCAs != nil ||
cfg.LogRemoteBufferLines != 10000 || cfg.LogRemoteFacility != 16 || cfg.LogRemoteBufferLines != 10000 || cfg.LogRemoteFacility != 16 ||
@@ -892,7 +892,7 @@ func TestRemoteLogSettingsAsSet(t *testing.T) {
t.Fatalf("write %s: %v", caFile, err) t.Fatalf("write %s: %v", caFile, err)
} }
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
logRemoteURL: "syslog+tls://logs.example:6514", logRemoteURL: "syslog+tls://logs.example:6514",
logRemoteTLSCAFile: caFile, logRemoteTLSCAFile: caFile,
logRemoteBufferLines: "500", logRemoteBufferLines: "500",
@@ -927,13 +927,13 @@ func TestRemoteLogURLForms(t *testing.T) {
"syslog+tcp://[2001:db8::1]:514", "syslog+tcp://[2001:db8::1]:514",
"syslog+tls://logs.example:6514/", "syslog+tls://logs.example:6514/",
} { } {
cfg := wafFromEnvironment(t, environment{logRemoteURL: value}) cfg := fromEnvironment(t, environment{logRemoteURL: value})
if cfg.LogRemoteURL.String() != value { if cfg.LogRemoteURL.String() != value {
t.Errorf("%s read as %v", value, cfg.LogRemoteURL) t.Errorf("%s read as %v", value, cfg.LogRemoteURL)
} }
} }
cfg := wafFromEnvironment(t, environment{logRemoteURL: ""}) cfg := fromEnvironment(t, environment{logRemoteURL: ""})
if cfg.LogRemoteURL != nil { if cfg.LogRemoteURL != nil {
t.Errorf( t.Errorf(
"set but empty, %s read as %v", "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, "kern": 0, "user": 1, "auth": 4, "authpriv": 10, "ftp": 11,
"local0": 16, "local5": 21, "local7": 23, "local0": 16, "local5": 21, "local7": 23,
} { } {
cfg := wafFromEnvironment(t, environment{logRemoteFacility: name}) cfg := fromEnvironment(t, environment{logRemoteFacility: name})
if cfg.LogRemoteFacility != number { if cfg.LogRemoteFacility != number {
t.Errorf( t.Errorf(
"%s read as %d, want %d", "%s read as %d, want %d",
@@ -1036,7 +1036,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
cfg := wafFromEnvironment(t, environment{instanceName: spaced}) cfg := fromEnvironment(t, environment{instanceName: spaced})
if cfg.LogRemoteAppName != spaced { if cfg.LogRemoteAppName != spaced {
t.Errorf( t.Errorf(
"not sending, %s is %q", "not sending, %s is %q",
@@ -1047,7 +1047,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(
sending[logRemoteAppName] = instance sending[logRemoteAppName] = instance
cfg = wafFromEnvironment(t, sending) cfg = fromEnvironment(t, sending)
if cfg.LogRemoteAppName != instance { if cfg.LogRemoteAppName != instance {
t.Errorf("set to %s, %s is %q", instance, logRemoteAppName, t.Errorf("set to %s, %s is %q", instance, logRemoteAppName,
cfg.LogRemoteAppName) cfg.LogRemoteAppName)
@@ -1073,7 +1073,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
func TestAlertSettingsDefaults(t *testing.T) { func TestAlertSettingsDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
if cfg.AlertWebhookURL != nil || len(cfg.AlertWebhookHeaders) != 0 || if cfg.AlertWebhookURL != nil || len(cfg.AlertWebhookHeaders) != 0 ||
cfg.AlertSlackWebhookURL != nil || cfg.AlertNtfyURL != nil || cfg.AlertSlackWebhookURL != nil || cfg.AlertNtfyURL != nil ||
@@ -1106,7 +1106,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
token = "tk_0123456789abcdefghijklmnopq" token = "tk_0123456789abcdefghijklmnopq"
) )
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
alertWebhookURL: webhook, alertWebhookURL: webhook,
alertWebhookHeaders: "Authorization: Bearer abc:def , x-team:ops", alertWebhookHeaders: "Authorization: Bearer abc:def , x-team:ops",
alertSlackWebhookURL: slack, alertSlackWebhookURL: slack,
@@ -1144,7 +1144,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
func TestAlertSettingsSetEmptyOrOff(t *testing.T) { func TestAlertSettingsSetEmptyOrOff(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
alertWebhookURL: "", alertSlackWebhookURL: "", alertNtfyURL: "", alertWebhookURL: "", alertSlackWebhookURL: "", alertNtfyURL: "",
alertNtfyToken: "", alertEvents: "", alertCooldown: off, alertMaxPerHour: off, alertNtfyToken: "", alertEvents: "", alertCooldown: off, alertMaxPerHour: off,
}) })
@@ -1202,7 +1202,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
const secret = "Bearer 0123456789abcdef" const secret = "Bearer 0123456789abcdef"
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
alertWebhookHeaders: "Authorization:" + secret + ",X-Team:ops", 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/********", "https://hooks.example:8443?token=" + secret: "https://hooks.example:8443/********",
"http://[2001:db8::1]:8080": "http://[2001:db8::1]:8080", "http://[2001:db8::1]:8080": "http://[2001:db8::1]:8080",
} { } {
cfg := wafFromEnvironment(t, environment{alertWebhookURL: value}) cfg := fromEnvironment(t, environment{alertWebhookURL: value})
var out bytes.Buffer var out bytes.Buffer
@@ -1278,7 +1278,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
const token = "tk_0123456789abcdefghijklmnopq" const token = "tk_0123456789abcdefghijklmnopq"
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef", alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
alertNtfyURL: "https://ntfy.example/sww-alerts", alertNtfyURL: "https://ntfy.example/sww-alerts",
alertNtfyToken: token, alertNtfyToken: token,
@@ -1345,7 +1345,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
cfg := wafFromEnvironment(t, environment{instanceName: name}) cfg := fromEnvironment(t, environment{instanceName: name})
if cfg.InstanceName != name { if cfg.InstanceName != name {
t.Errorf( t.Errorf(
"not sending to ntfy, %s is %q", "not sending to ntfy, %s is %q",
@@ -1396,7 +1396,7 @@ func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
func TestLookupSettingsAsSet(t *testing.T) { func TestLookupSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
asnAndCountryLookupTimeout: "500", addLookupHeaders: enabled, asnAndCountryLookupTimeout: "500", addLookupHeaders: enabled,
}) })
wantLookupSettings(t, cfg, config.WAF{ wantLookupSettings(t, cfg, config.WAF{
@@ -1404,7 +1404,7 @@ func TestLookupSettingsAsSet(t *testing.T) {
AddLookupHeaders: true, AddLookupHeaders: true,
}) })
cfg = wafFromEnvironment(t, environment{lookupSource: off}) cfg = fromEnvironment(t, environment{lookupSource: off})
wantLookupSettings(t, cfg, config.WAF{ wantLookupSettings(t, cfg, config.WAF{
LookupSource: off, ASNAndCountryLookupTimeout: 800 * time.Millisecond, LookupSource: off, ASNAndCountryLookupTimeout: 800 * time.Millisecond,
}) })
@@ -1415,7 +1415,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
const path = "/var/lib/ipinfo/ipinfo_lite.mmdb" const path = "/var/lib/ipinfo/ipinfo_lite.mmdb"
cfg := wafFromEnvironment( cfg := fromEnvironment(
t, t,
environment{lookupSource: fileSource, lookupDBPath: path}, environment{lookupSource: fileSource, lookupDBPath: path},
) )
@@ -1508,13 +1508,13 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
} }
} }
wafFromEnvironment(t, env) fromEnvironment(t, env)
} }
func TestAnomalySettingsDefaults(t *testing.T) { func TestAnomalySettingsDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
wantAllOff(t, cfg) wantAllOff(t, cfg)
if cfg.AnomalyNetV4Prefix != 24 || cfg.AnomalyNetV6Prefix != 48 || if cfg.AnomalyNetV4Prefix != 24 || cfg.AnomalyNetV6Prefix != 48 ||
@@ -1556,7 +1556,7 @@ func TestAnomalySettingsAsSet(t *testing.T) {
} }
} }
cfg := wafFromEnvironment(t, env) cfg := fromEnvironment(t, env)
if got := thresholdsByScope(cfg); !maps.Equal(got, want) { if got := thresholdsByScope(cfg); !maps.Equal(got, want) {
t.Errorf("thresholds by scope\n%+v\nwant\n%+v", 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 env[name] = off
} }
wantAllOff(t, wafFromEnvironment(t, env)) wantAllOff(t, fromEnvironment(t, env))
} }
// thresholdsByScope returns cfg's anomaly thresholds, each by the prefix // 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 // Names and tags in either case, an AS number with leading zeros, and a
// name holding a =. // name holding a =.
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
tagLists: "TorExit=" + torURL + tagLists: "TorExit=" + torURL +
", vpn=mullvad=https://example.com/mullvad.txt", ", vpn=mullvad=https://example.com/mullvad.txt",
dnsblZones: "Zen.example", dnsblZones: "Zen.example",
@@ -1717,7 +1717,7 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
const rw = "https://routewatch.example" const rw = "https://routewatch.example"
if cfg := wafFromEnvironment(t, environment{}); cfg.RouteWatchURL != "" { if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
t.Errorf( t.Errorf(
"default %s is %q, want unset", "default %s is %q, want unset",
routeWatchURL, routeWatchURL,
@@ -1725,7 +1725,7 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
) )
} }
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32", routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
tagLimitPercent: "prefix=192.0.2.0/24:50", 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. // routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
wafFromEnvironment(t, environment{ fromEnvironment(t, environment{
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496", lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
}) })
@@ -1919,7 +1919,7 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
func TestBiasedThresholdsAsSet(t *testing.T) { func TestBiasedThresholdsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
if len(cfg.ASNBytesLimitPercent) != 0 || if len(cfg.ASNBytesLimitPercent) != 0 ||
len(cfg.CountryBytesLimitPercent) != 0 || len(cfg.CountryBytesLimitPercent) != 0 ||
cfg.UnknownCountryLimitPercent != 100 { cfg.UnknownCountryLimitPercent != 100 {
@@ -1934,7 +1934,7 @@ func TestBiasedThresholdsAsSet(t *testing.T) {
// AS numbers and countries in either case, an AS number with leading // AS numbers and countries in either case, an AS number with leading
// zeros, 0 and 100. // zeros, 0 and 100.
cfg = wafFromEnvironment(t, environment{ cfg = fromEnvironment(t, environment{
asnBytesLimitPercent: "as16276:75", asnBytesLimitPercent: "as16276:75",
countryBytesLimitPercent: "ru:10", countryBytesLimitPercent: "ru:10",
unknownCountryLimitPercent: "0", unknownCountryLimitPercent: "0",
@@ -2016,7 +2016,7 @@ const (
func TestReputationSettingsAsSet(t *testing.T) { func TestReputationSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
if len(cfg.BlocklistURLs) != 0 || cfg.BlocklistRefresh != 24*time.Hour || if len(cfg.BlocklistURLs) != 0 || cfg.BlocklistRefresh != 24*time.Hour ||
cfg.BlocklistAction != actionDeny || cfg.ASNLimitPercentURL != "" { cfg.BlocklistAction != actionDeny || cfg.ASNLimitPercentURL != "" {
t.Errorf( t.Errorf(
@@ -2043,7 +2043,7 @@ func TestReputationSettingsAsSet(t *testing.T) {
{"limit:0", actionLimit, 0}, {"limit:0", actionLimit, 0},
} { } {
// An hour, the shortest refresh allowed. // An hour, the shortest refresh allowed.
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
blocklistURLs: dropURL + ", " + torURL, blocklistRefresh: "1h", blocklistURLs: dropURL + ", " + torURL, blocklistRefresh: "1h",
blocklistAction: tc.value, asnLimitPercentURL: asnURL, blocklistAction: tc.value, asnLimitPercentURL: asnURL,
}) })
@@ -2140,7 +2140,7 @@ const (
func TestDNSBLSettingsAsSet(t *testing.T) { func TestDNSBLSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
if len(cfg.DNSBLZones) != 0 || cfg.DNSBLResolver.IsValid() || if len(cfg.DNSBLZones) != 0 || cfg.DNSBLResolver.IsValid() ||
cfg.DNSBLAbuseIPDBAction != actionLimit || cfg.DNSBLAbuseIPDBAction != actionLimit ||
cfg.DNSBLAbuseIPDBLimitPercent != 25 || cfg.DNSBLAbuseIPDBLimitPercent != 25 ||
@@ -2188,7 +2188,7 @@ func TestDNSBLSettingsAsSet(t *testing.T) {
[]string{dronebl}, "[2001:db8::53]:5353", actionLimit, 0, []string{dronebl}, "[2001:db8::53]:5353", actionLimit, 0,
}, },
} { } {
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
dnsblZones: tc.zones, dnsblResolver: tc.resolver, dnsblAbuseIPDBAction: tc.action, dnsblZones: tc.zones, dnsblResolver: tc.resolver, dnsblAbuseIPDBAction: tc.action,
ipReputationCacheTTL: "12h", dnsblAbuseIPDBTimeout: "3000", ipReputationCacheTTL: "12h", dnsblAbuseIPDBTimeout: "3000",
}) })
@@ -2310,7 +2310,7 @@ func TestMaskZoneKeyMasksTheFirstLabelOfAZoneUnderDqSpamhausNet(
func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) { func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment( cfg := fromEnvironment(
t, t,
environment{dnsblZones: dronebl + ", " + spamhaus}, environment{dnsblZones: dronebl + ", " + spamhaus},
) )
@@ -2375,7 +2375,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
func TestAbuseIPDBSettingsAsSet(t *testing.T) { func TestAbuseIPDBSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 || if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 ||
cfg.AbuseIPDBDailyBudget != 900 { cfg.AbuseIPDBDailyBudget != 900 {
t.Errorf( t.Errorf(
@@ -2387,7 +2387,7 @@ func TestAbuseIPDBSettingsAsSet(t *testing.T) {
) )
} }
cfg = wafFromEnvironment(t, environment{ cfg = fromEnvironment(t, environment{
abuseIPDBKey: token, abuseIPDBMinScore: "0", abuseIPDBDailyBudget: "1", abuseIPDBKey: token, abuseIPDBMinScore: "0", abuseIPDBDailyBudget: "1",
}) })
if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 || if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 ||
@@ -2442,7 +2442,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(
func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) { func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{abuseIPDBKey: token}) cfg := fromEnvironment(t, environment{abuseIPDBKey: token})
var out bytes.Buffer var out bytes.Buffer
@@ -2459,7 +2459,7 @@ func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) { func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
if cfg.CrowdSecDecisionsURL != "" || cfg.CrowdSecKey != "" { if cfg.CrowdSecDecisionsURL != "" || cfg.CrowdSecKey != "" {
t.Errorf( t.Errorf(
"by default, the decision list %q and the key %q, want neither", "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/lapi/": "https://crowdsec.example/lapi/v1/decisions",
"https://crowdsec.example:8443/x": "https://crowdsec.example:8443/x/v1/decisions", "https://crowdsec.example:8443/x": "https://crowdsec.example:8443/x/v1/decisions",
} { } {
cfg := wafFromEnvironment( cfg := fromEnvironment(
t, t,
environment{crowdSecURL: lapi, crowdSecKey: token}, environment{crowdSecURL: lapi, crowdSecKey: token},
) )
@@ -2568,7 +2568,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
func TestCrowdSecKeyIsLoggedMasked(t *testing.T) { func TestCrowdSecKeyIsLoggedMasked(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
crowdSecURL: "http://172.17.0.1:8080", crowdSecKey: token, crowdSecURL: "http://172.17.0.1:8080", crowdSecKey: token,
}) })
@@ -2591,7 +2591,7 @@ func TestCrowdSecKeyIsLoggedMasked(t *testing.T) {
func TestSizesAndOff(t *testing.T) { func TestSizesAndOff(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
bytesLimitPerMinute: "3G", bytesLimitPerMinute: "3G",
bytesLimitPerHour: off, bytesLimitPerHour: off,
alertCooldown: off, alertCooldown: off,
@@ -2611,7 +2611,7 @@ func TestSizesAndOff(t *testing.T) {
func TestRateLimitsOff(t *testing.T) { func TestRateLimitsOff(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
rateLimitPerMinute: off, rateLimitPerMinute: off,
rateLimitPerHour: off, rateLimitPerHour: off,
rateLimitPerDay: off, rateLimitPerDay: off,
@@ -2631,7 +2631,7 @@ func TestRateLimitsOff(t *testing.T) {
func TestBanResponseCloseIsZero(t *testing.T) { func TestBanResponseCloseIsZero(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{banResponse: "close"}) cfg := fromEnvironment(t, environment{banResponse: "close"})
if cfg.BanResponse != 0 { if cfg.BanResponse != 0 {
t.Errorf("close read as %d, want 0", cfg.BanResponse) t.Errorf("close read as %d, want 0", cfg.BanResponse)
} }
@@ -2883,7 +2883,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
func TestTokensAreReadAndLoggedMasked(t *testing.T) { func TestTokensAreReadAndLoggedMasked(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment( cfg := fromEnvironment(
t, t,
environment{adminToken: otherToken, metricsToken: token}, environment{adminToken: otherToken, metricsToken: token},
) )
@@ -2915,7 +2915,7 @@ func TestSettingFromFileLosesOneNewlineAndNoMore(t *testing.T) {
token + "\n\n": token + "\n", token + "\n\n": token + "\n",
token + " \n": token + " ", token + " \n": token + " ",
} { } {
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
metricsToken + "_FILE": writeFile(t, contents), metricsToken + "_FILE": writeFile(t, contents),
}) })
if cfg.MetricsToken != want { if cfg.MetricsToken != want {
@@ -2991,7 +2991,7 @@ func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) {
t.Parallel() t.Parallel()
path := writeFile(t, token+"\n") path := writeFile(t, token+"\n")
cfg := wafFromEnvironment(t, environment{metricsToken + "_FILE": path}) cfg := fromEnvironment(t, environment{metricsToken + "_FILE": path})
var out bytes.Buffer var out bytes.Buffer
@@ -3021,7 +3021,7 @@ func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) {
func TestRemoteLogCAFileIsNotReadAsAFileInItsTurn(t *testing.T) { func TestRemoteLogCAFileIsNotReadAsAFileInItsTurn(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
logRemoteTLSCAFile + "_FILE": writeFile(t, "/nonexistent/ca.pem\n"), logRemoteTLSCAFile + "_FILE": writeFile(t, "/nonexistent/ca.pem\n"),
}) })
if cfg.LogRemoteTLSCAs != nil { if cfg.LogRemoteTLSCAs != nil {
@@ -3048,7 +3048,7 @@ func writeFile(t *testing.T, contents string) string {
func TestLogsEachSettingWithItsValue(t *testing.T) { func TestLogsEachSettingWithItsValue(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment(t, environment{rateLimitPerMinute: "45"}) cfg := fromEnvironment(t, environment{rateLimitPerMinute: "45"})
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
want := map[string]string{ want := map[string]string{
-4
View File
@@ -919,10 +919,6 @@ const retypeKey = "retype"
// system files (930120) holds. // system files (930120) holds.
const htaccess = ".htaccess" 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 // 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. // value. An app's tests check that its fields let them through.
// //
+1 -1
View File
@@ -145,7 +145,7 @@ func TestForgejoExclusionsAreNarrow(t *testing.T) {
"/alice/-/packages/npm/x?q=", "/alice/-/packages/npm/x?q=",
"/alice/tools/branches?other=", "/alice/tools/branches?other=",
} { } {
for _, value := range slices.Concat([]string{binSh}, hardValues) { for _, value := range slices.Concat([]string{"%2Fbin%2Fsh"}, hardValues) {
if got := inspect(t, crs, get(target+value)); got.Score < threshold { if got := inspect(t, crs, get(target+value)); got.Score < threshold {
t.Errorf("%s%s let through: %+v", target, value, got) t.Errorf("%s%s let through: %+v", target, value, got)
} }
+7 -249
View File
@@ -7,8 +7,7 @@
# #
# Ghost's admin and its integrations call the Admin API under /ghost/api/admin/, # 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/, # themes and Ghost's own scripts call the Content API under /ghost/api/content/,
# and members call /members/api/. The admin calls ActivityPub, a service of its # and members call /members/api/. Coraza names a field of a JSON body by 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. # 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 # Every rule below is skipped unless Inspect has set tx.app_ghost. It sets it
@@ -18,22 +17,11 @@
SecRule &TX:app_ghost "@eq 0" \ SecRule &TX:app_ghost "@eq 0" \
"id:900365,phase:1,pass,nolog,skipAfter:END_GHOST" "id:900365,phase:1,pass,nolog,skipAfter:END_GHOST"
# filter, a query in Ghost's own filter language, skips seven rules on the # filter, a query in Ghost's own filter language, skips 932120 on the Admin API
# Admin API and the Content API: # and the Content API: filter=tag:powershell, filter=name:~'powershell'.
# 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)/" \ SecRule REQUEST_FILENAME "@rx ^/ghost/api/(?:admin|content)/" \
"id:900350,phase:1,pass,nolog,\ "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 # search, the search for a member by name or address, skips four rules on the
# Admin API: # Admin API:
@@ -57,61 +45,10 @@ SecRule REQUEST_FILENAME "@streq /ghost/api/admin/oembed/" \
ctl:ruleRemoveTargetById=930120;ARGS:url,\ ctl:ruleRemoveTargetById=930120;ARGS:url,\
ctl:ruleRemoveTargetById=932120;ARGS:url" ctl:ruleRemoveTargetById=932120;ARGS:url"
# The query fields that filter the analytics skip the eight rules that 900353 # The fields a person types plain text into skip seven rules on the Admin API:
# 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" # 930120: the title "Using python3 and ls -la to tidy .htaccess files"
# 932120: the page's slug about-python3-powershell-htaccess # 932120: the page's slug about-python3-powershell-htaccess
# 932125, 932230, 932250: the tag "Linux & ls -la" # 932125, 932230, 932250: the tag "Linux & ls -la"
# 932235: the title "powershell & python3"
# 932260: the tag python3 # 932260: the tag python3
# 932380: the newsletter "Shell tips weekly: python3 & powershell" # 932380: the newsletter "Shell tips weekly: python3 & powershell"
# These fields hold plain text: # These fields hold plain text:
@@ -127,12 +64,11 @@ 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=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=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=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=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=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))?)$/" 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 eight rules. # name, the name a member saves at /members/api/, skips the same seven rules.
SecRule REQUEST_FILENAME "@beginsWith /members/api/" \ SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
"id:900359,phase:1,pass,nolog,chain" "id:900359,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.name$/ "@eq 0" \ SecRule &ARGS_GET:/^json\.name$/ "@eq 0" \
@@ -140,99 +76,10 @@ SecRule REQUEST_FILENAME "@beginsWith /members/api/" \
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.name$/,\ ctl:ruleRemoveTargetById=932120;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.name$/,\ ctl:ruleRemoveTargetById=932125;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.name$/,\ ctl:ruleRemoveTargetById=932230;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.name$/,\ ctl:ruleRemoveTargetById=932250;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.name$/,\ ctl:ruleRemoveTargetById=932260;ARGS:/^json\.name$/,\
ctl:ruleRemoveTargetById=932380;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 # A member's email address skips 930120, 932120 and 932260, which refused
# erin.htaccess@example.com, dave.powershell@example.com and # erin.htaccess@example.com, dave.powershell@example.com and
# python3.dev@example.com. These fields hold it: # python3.dev@example.com. These fields hold it:
@@ -285,63 +132,6 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.users\.\d+\.email$/,\ ctl:ruleRemoveTargetById=932120;ARGS:/^json\.users\.\d+\.email$/,\
ctl:ruleRemoveTargetById=932260;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: # The articles skip these rules on the Admin API:
# 930120: ls -la /var/www/html/.htaccess in a code block # 930120: ls -la /var/www/html/.htaccess in a code block
# 932120: powershell -ExecutionPolicy Bypass -File build.ps1 # 932120: powershell -ExecutionPolicy Bypass -File build.ps1
@@ -388,10 +178,9 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
# Code injection, the HTML Ghost adds to the head and the foot of a page, and # 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 # the site's settings, which hold the site-wide code injection besides the
# title and the description, skip the eight rules that 900353 lists and these: # title and the description, skip the rules of plain text above and these:
# 921130, 941100, 941160: <meta name="google-site-verification" ...> # 921130, 941100, 941160: <meta name="google-site-verification" ...>
# 941270: <link rel="preconnect" href="https://fonts.example.com"> # 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: # 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 # posts.<n>.codeinjection_head and _foot, and the same fields of pages
# settings.<n>.value, the value of a setting # settings.<n>.value, the value of a setting
@@ -405,7 +194,6 @@ SecRule REQUEST_FILENAME "@beginsWith /ghost/api/admin/" \
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\ 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=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=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=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=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)$/,\ ctl:ruleRemoveTargetById=932380;ARGS:/^json\.(?:(?:posts|pages)\.\d+\.codeinjection_(?:head|foot)|settings\.\d+\.value)$/,\
@@ -452,36 +240,6 @@ SecRule REQUEST_FILENAME "@rx ^/ghost/api/admin/users/[0-9a-f]{24}/$" \
SecRule &ARGS_GET:/^json\.users\.\d+\.profile_image$/ "@eq 0" \ SecRule &ARGS_GET:/^json\.users\.\d+\.profile_image$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS_NAMES:/^json\.users\.\d+\.profile_image$/" "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: # The passwords, which skip each rule that random passwords set off:
# password, signing in at /ghost/api/admin/session/ # password, signing in at /ghost/api/admin/session/
# setup.<n>.password, creating the first staff user # setup.<n>.password, creating the first staff user
+23 -439
View File
@@ -28,19 +28,6 @@ const (
ghostUser = "/ghost/api/admin/users/6af4570fd281b00001a59891/" ghostUser = "/ghost/api/admin/users/6af4570fd281b00001a59891/"
ghostMember = "/members/api/member/" ghostMember = "/members/api/member/"
ghostMagicLink = "/members/api/send-magic-link/" 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) { func TestGhostFixesApplyOnlyToAHostnameThatServesGhost(t *testing.T) {
@@ -77,7 +64,7 @@ func (f ghostField) with(t *testing.T, value string) string {
) )
} }
// ghostTextFields hold plain text and skip the eight rules that 900353 lists. // ghostTextFields hold plain text and skip the rules of 900353 and 900359.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var ghostTextFields = []ghostField{ var ghostTextFields = []ghostField{
@@ -129,111 +116,8 @@ var ghostTextFields = []ghostField{
`{"snippets":[{"name":"VALUE"}]}`, `{"snippets":[{"name":"VALUE"}]}`,
}, },
{http.MethodPut, ghostMember, ghostMemberName}, {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/. // ghostMemberName is the body that saves a member's name at /members/api/.
const ghostMemberName = `{"name":"VALUE"}` const ghostMemberName = `{"name":"VALUE"}`
@@ -251,8 +135,6 @@ var ghostAddressFields = []ghostField{
{http.MethodPost, ghostMembers, `{"members":[{"email":"VALUE"}]}`}, {http.MethodPost, ghostMembers, `{"members":[{"email":"VALUE"}]}`},
{http.MethodPost, ghostSession, `{"username":"VALUE"}`}, {http.MethodPost, ghostSession, `{"username":"VALUE"}`},
{http.MethodPost, ghostSetup, `{"setup":[{"email":"VALUE"}]}`}, {http.MethodPost, ghostSetup, `{"setup":[{"email":"VALUE"}]}`},
{http.MethodPost, ghostInvites, `{"invites":[{"email":"VALUE"}]}`},
{http.MethodPost, ghostInvitation, `{"invitation":[{"email":"VALUE"}]}`},
{ {
http.MethodPost, http.MethodPost,
ghostPasswordReset, ghostPasswordReset,
@@ -261,41 +143,14 @@ var ghostAddressFields = []ghostField{
{http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`}, {http.MethodPut, ghostUser, `{"users":[{"email":"VALUE"}]}`},
} }
// ghostHandleFields hold an account's handle in ActivityPub, which the admin // ghostCanonicalURLFields hold the canonical URL of a post or a page, and skip
// sends as an @ followed by an address, with no spaces. They skip 930120 and // 930120 and 932120, as url does.
// 932120.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var ghostHandleFields = []ghostField{ var ghostCanonicalURLFields = []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.MethodPut, ghostPost, `{"posts":[{"canonical_url":"VALUE"}]}`},
{http.MethodPost, ghostPosts, `{"posts":[{"canonical_url":"VALUE"}]}`}, {http.MethodPost, ghostPosts, `{"posts":[{"canonical_url":"VALUE"}]}`},
{http.MethodPut, ghostPage, `{"pages":[{"canonical_url":"VALUE"}]}`}, {http.MethodPut, ghostPage, `{"pages":[{"canonical_url":"VALUE"}]}`},
{http.MethodPost, ghostTags, `{"tags":[{"canonical_url":"VALUE"}]}`},
{
http.MethodPut,
ghostTags + "6af45d4ed281b00001a599a8/",
`{"tags":[{"canonical_url":"VALUE"}]}`,
},
{http.MethodDelete, ghostAliases, `{"actorUri":"VALUE"}`},
} }
// ghostPostFields hold a post or a page, skip the rules of 900355, and take a // ghostPostFields hold a post or a page, skip the rules of 900355, and take a
@@ -338,8 +193,8 @@ var ghostArticleFields = slices.Concat(
ghostSnippetAndNewsletterFooterFields, ghostSnippetAndNewsletterFooterFields,
) )
// ghostCodeFields hold code injection or a setting, skip the rules of 900356 // ghostCodeFields hold code injection or a setting, skip the rules of 900356,
// and 900378, and take a <script> element by 900366 and 900378. // and take a <script> element by 900366.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var ghostCodeFields = []ghostField{ var ghostCodeFields = []ghostField{
@@ -358,16 +213,6 @@ var ghostCodeFields = []ghostField{
`{"settings":[{"key":"title","value":"Blog"},` + `{"settings":[{"key":"title","value":"Blog"},` +
`{"key":"codeinjection_head","value":"VALUE"}]}`, `{"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 "=". // The query fields that skip some rules, each ending in "=".
@@ -376,42 +221,17 @@ const (
ghostContentFilter = "/ghost/api/content/posts/?key=c695aa1f5cdfa62f7b677c3c83&filter=" ghostContentFilter = "/ghost/api/content/posts/?key=c695aa1f5cdfa62f7b677c3c83&filter="
ghostSearch = ghostMembers + "?limit=100&search=" ghostSearch = ghostMembers + "?limit=100&search="
ghostEmbed = "/ghost/api/admin/oembed/?type=bookmark&url=" 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. // ghostQueryFields are the query fields that skip some rules.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var ghostQueryFields = []string{ var ghostQueryFields = []string{
ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed, ghostAdminFilter, ghostContentFilter, ghostSearch, ghostEmbed,
ghostPathname, ghostSource, ghostCampaign, ghostSegment,
ghostAccountSearch, ghostInvitedEmail,
} }
// ghostCommandNames joins two command names, as an ordinary title can. // ghostURLs are addresses 930120 and 932120 refused in url and in a canonical
const ghostCommandNames = "powershell & python3" // URL.
// ghostURLs are addresses 930120 and 932120 refused in url and in
// ghostURLFields.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var ghostURLs = []string{ var ghostURLs = []string{
@@ -431,15 +251,6 @@ var ghostAddresses = []string{
"dave.powershell@example.com", "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 // ghostArticle is an ordinary article with a code block, an HTML card and an
// embed, which each of the rules of 900355 refused. // embed, which each of the rules of 900355 refused.
const ghostArticle = `<h2>Tidy .htaccess with python3</h2>` + const ghostArticle = `<h2>Tidy .htaccess with python3</h2>` +
@@ -530,23 +341,17 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
for _, address := range ghostURLs { for _, address := range ghostURLs {
wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false) wantGhostQuery(t, ghostEmbed+url.QueryEscape(address), false)
for _, f := range ghostURLFields { for _, f := range ghostCanonicalURLFields {
wantGhostField(t, f, address, false) wantGhostField(t, f, address, false)
} }
} }
for _, address := range ghostAddresses { for _, address := range ghostAddresses {
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields) { for _, f := range ghostAddressFields {
wantGhostField(t, f, address, false) wantGhostField(t, f, address, false)
} }
} }
for _, domain := range ghostDomains {
for _, f := range ghostDomainFields {
wantGhostField(t, f, domain, false)
}
}
for _, f := range ghostArticleFields { for _, f := range ghostArticleFields {
wantGhostField(t, f, ghostArticle, false) wantGhostField(t, f, ghostArticle, false)
} }
@@ -555,14 +360,6 @@ func TestGhostOrdinaryValuesPassInTheExcludedFields(t *testing.T) {
wantGhostField(t, f, ghostCodeInjection, false) 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="+ wantGhostQuery(t, "/ghost/api/admin/posts/?filter="+
url.QueryEscape( url.QueryEscape(
"tag:powershell+status:[draft,scheduled,published,sent]", "tag:powershell+status:[draft,scheduled,published,sent]",
@@ -596,8 +393,7 @@ func TestGhostScriptsPassInPostsAndCodeInjection(t *testing.T) {
wantGhostField(t, f, ghostWidget, false) wantGhostField(t, f, ghostWidget, false)
} }
// Site code injection, saved in settings, and a post's, a page's or a // Site code injection, saved in settings, and a post's or a page's own.
// tag's own.
for _, f := range ghostCodeFields { for _, f := range ghostCodeFields {
wantGhostField(t, f, ghostAnalytics, false) wantGhostField(t, f, ghostAnalytics, false)
} }
@@ -616,8 +412,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
for _, attack := range ghostAttacks { for _, attack := range ghostAttacks {
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields, for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
ghostHandleFields, ghostDomainFields, ghostURLFields, ghostCanonicalURLFields, ghostArticleFields, ghostCodeFields) {
ghostArticleFields, ghostCodeFields) {
wantGhostField(t, f, attack, true) wantGhostField(t, f, attack, true)
} }
@@ -629,8 +424,7 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
// A script injection stays refused in every field that does not take a // A script injection stays refused in every field that does not take a
// <script> element. // <script> element.
for _, f := range slices.Concat(ghostTextFields, ghostAddressFields, for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
ghostHandleFields, ghostDomainFields, ghostURLFields, ghostCanonicalURLFields, ghostSnippetAndNewsletterFooterFields) {
ghostSnippetAndNewsletterFooterFields) {
wantGhostField(t, f, scriptTag, true) wantGhostField(t, f, scriptTag, true)
} }
@@ -638,19 +432,15 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
wantGhostQuery(t, target+url.QueryEscape(scriptTag), true) wantGhostQuery(t, target+url.QueryEscape(scriptTag), true)
} }
// A command after a separator stays refused wherever 932235 does not // A command after a separator stays refused wherever its rules do not
// misfire: in the addresses, handles, the domain, ghostURLFields, search, // misfire: in the query fields, the plain text, the addresses and the
// url, pathname, email_segment and email. The other fields skip 932235, // canonical URLs.
// which refuses "powershell & python3" as it refuses this. for _, f := range slices.Concat(ghostTextFields, ghostAddressFields,
for _, f := range slices.Concat(ghostAddressFields, ghostHandleFields, ghostCanonicalURLFields) {
ghostDomainFields, ghostURLFields) {
wantGhostField(t, f, commandAfterASeparator, true) wantGhostField(t, f, commandAfterASeparator, true)
} }
for _, target := range []string{ for _, target := range ghostQueryFields {
ghostSearch, ghostEmbed, ghostPathname, ghostSegment,
ghostInvitedEmail,
} {
wantGhostQuery( wantGhostQuery(
t, t,
target+url.QueryEscape(commandAfterASeparator), target+url.QueryEscape(commandAfterASeparator),
@@ -658,15 +448,6 @@ 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 // The articles and code injection carry HTML on purpose, but a
// javascript: link is still refused there, and a script element in // javascript: link is still refused there, and a script element in
// snippets and a newsletter's footer. // snippets and a newsletter's footer.
@@ -678,138 +459,12 @@ func TestGhostAttacksAreRefusedInTheExcludedFields(t *testing.T) {
wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true) wantGhostField(t, f, `<p>Hi</p><script src="/x.js"></script>`, true)
} }
// An address and the domain keep 932250, which refuses a listing of // An address keeps 932250, which refuses a listing of files.
// files. A handle keeps it too, but its @ comes before any command. for _, f := range ghostAddressFields {
for _, f := range slices.Concat(ghostAddressFields, ghostDomainFields) {
wantGhostField(t, f, "ls -la /root", true) 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) { func TestGhostExclusionsAreNarrow(t *testing.T) {
t.Parallel() t.Parallel()
@@ -847,6 +502,7 @@ func TestGhostExclusionsAreNarrow(t *testing.T) {
`{"settings":[{"value":"<meta name=x content=y>"}]}`, `{"settings":[{"value":"<meta name=x content=y>"}]}`,
}, },
{"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`}, {"/ghost/api/admin/members/", `{"members":[{"geolocation":"python3"}]}`},
{"/ghost/api/admin/invites/", `{"invites":[{"email":"python3.dev@example.com"}]}`},
{ghostPosts, email}, {ghostPosts, email},
{ghostMembers, email}, {ghostMembers, email},
{ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`}, {ghostMember, `{"members":[{"email":"python3.dev@example.com"}]}`},
@@ -904,7 +560,6 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
script = `<script defer src=\"/script.js\"></script>` script = `<script defer src=\"/script.js\"></script>`
scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}` scriptInPost = `{"posts":[{"lexical":"` + script + `"}]}`
scriptInSettings = `{"settings":[{"value":"` + script + `"}]}` scriptInSettings = `{"settings":[{"value":"` + script + `"}]}`
scriptInTag = `{"tags":[{"codeinjection_head":"` + script + `"}]}`
) )
for _, refused := range []struct{ target, body string }{ for _, refused := range []struct{ target, body string }{
@@ -914,8 +569,6 @@ func TestGhostScriptExclusionIsNarrow(t *testing.T) {
{"/ghost/api/admin/users/me/", `{"users":[{"bio":"` + script + `"}]}`}, {"/ghost/api/admin/users/me/", `{"users":[{"bio":"` + script + `"}]}`},
{"/api/settings/", scriptInSettings}, {"/api/settings/", scriptInSettings},
{ghostSettings + "?json.settings.0.value=x", 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}} r := request{http.MethodPut, refused.target, []string{jsonBody}}
@@ -937,8 +590,7 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
// The query fields keep their rules off Ghost's paths, and in other // 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 // query fields there. search skips its rules only on the Admin API, and
// url only on the editor's lookup of embeds. pathname, email_segment, // url only on the editor's lookup of embeds.
// query and email skip theirs only on their own paths.
for _, target := range []string{ for _, target := range []string{
"/api/posts/?filter=powershell", "/api/posts/?filter=powershell",
"/ghost/posts/?filter=powershell", "/ghost/posts/?filter=powershell",
@@ -947,79 +599,11 @@ func TestGhostQueryExclusionsAreNarrow(t *testing.T) {
"/members/api/member/?search=python3", "/members/api/member/?search=python3",
"/ghost/api/admin/posts/?url=powershell", "/ghost/api/admin/posts/?url=powershell",
"/oembed/?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) 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) { func TestGhostPasswordsPass(t *testing.T) {
t.Parallel() t.Parallel()
+5 -32
View File
@@ -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 # 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 # npm's @scope/name do. So q on a repository's branches, tags and packages
# pages, on an owner's packages page, on the admin page of packages and on the # pages, on an owner's packages page and on the admin page of packages also
# API's search of packages also skips this rule: # skips this rule:
# 932160: /bin/sh # 932160: /bin/sh
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
admin/packages|api/v1/packages/[^/]+)$" \ admin/packages)$" \
"id:900605,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932160;ARGS:q" "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 # gitea's other searches send a name or a few words in q too, which skips only
@@ -188,9 +188,7 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
# The searches: # The searches:
# - the dashboard's filter of repositories; # - the dashboard's filter of repositories;
# - the author filter of the issue and pull request lists; # - the author filter of the issue and pull request lists;
# - the lists of users and organisations; # - the lists of users and organisations, and the API's search for users;
# - the API's searches of users, of topics, of an organisation's teams and of
# emails;
# - the list of repositories a user watches; # - the list of repositories a user watches;
# - an organisation's milestones of one team, and its search for teams; # - 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 # - the milestones of a repository or an organisation, and the projects of a
@@ -207,9 +205,7 @@ SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+/[^/]+/(?:branches|tags|packages)|\
# versions holds the package's name, which can hold a slash, as npm's # versions holds the package's name, which can hold a slash, as npm's
# @scope/name does. # @scope/name does.
SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\ SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
explore/(?:users|organizations)|\ explore/(?:users|organizations)|api/v1/users/search|\
api/v1/(?:users/search|topics/search|orgs/[^/]+/teams/search|\
admin/emails/search)|\
org/[^/]+/(?:milestones/[^/]+|teams/-/search)|\ org/[^/]+/(?:milestones/[^/]+|teams/-/search)|\
[^/]+/-/packages/[^/]+/.+/versions|\ [^/]+/-/packages/[^/]+/.+/versions|\
[^/]+/[^/]+/(?:(?:issues|pulls)/posters|issues/suggestions|milestones|\ [^/]+/[^/]+/(?:(?:issues|pulls)/posters|issues/suggestions|milestones|\
@@ -223,29 +219,6 @@ SecRule REQUEST_FILENAME "@rx ^/(?:repo/search|notifications/watching|\
ctl:ruleRemoveTargetById=932250;ARGS:q,\ ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;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 # 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 # 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: # segment, other apps' pages such as /index.php included:
+1 -101
View File
@@ -3,7 +3,6 @@ package coreruleset_test
import ( import (
"net/http" "net/http"
"slices" "slices"
"strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset" "sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -183,7 +182,7 @@ func TestGiteaSearchExclusionsAreNarrow(t *testing.T) {
} }
} }
attacks := []string{binSh, commandInjection, pathTraversal} attacks := []string{"%2Fbin%2Fsh", commandInjection, pathTraversal}
// /bin/sh, a shell command after a ";", a path traversal and the hard // /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 // values stay refused in q on paths near the searches, and in another
@@ -249,102 +248,3 @@ 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)
}
}
}
}
-60
View File
@@ -974,63 +974,3 @@
{"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/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": "/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": ""} {"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": ""}
-30
View File
@@ -762,33 +762,3 @@
{"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/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/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"} {"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 &amp; powershell, photo by <a href=\\\"https://example.com/ls-la\\\">Erin</a>\"}]}"}
{"method": "POST", "target": "/ghost/api/admin/members/upload/", "headers": ["Host: example.com", "Content-Length: 990", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "content-type: multipart/form-data; boundary=----WebKitFormBoundaryq3Lm8vXc2TzR7wKd", "app-pragma: no-cache", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": "------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"membersfile\"; filename=\"members.csv\"\r\nContent-Type: text/csv\r\n\r\nemail,name,note,labels\r\npython3.dev@example.com,Grace python3,Asked about ls -la,powershell\r\nerin.htaccess@example.com,Erin,Tidies .htaccess files,python3\r\n\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"labels\"\r\n\r\npython3 import\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"labels\"\r\n\r\npowershell & .htaccess\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[email]\"\r\n\r\nemail\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[name]\"\r\n\r\nname\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[note]\"\r\n\r\nnote\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd\r\nContent-Disposition: form-data; name=\"mapping[labels]\"\r\n\r\nlabels\r\n------WebKitFormBoundaryq3Lm8vXc2TzR7wKd--\r\n"}
{"method": "GET", "target": "/ghost/api/admin/stats/top-content/?date_from=2026-10-13&date_to=2026-11-12&timezone=Etc%2FUTC&member_status=all&pathname=%2Fpython3-and-powershell%2F", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "app-pragma: no-cache", "Accept: application/json, text/plain, */*", "Origin: http://example.com", "Referer: http://example.com/ghost/", "Accept-Encoding: gzip, deflate", "Cookie: ghost-admin-api-session=s%3A0oed4vmdPUacOz5202Gz4DuzSj2EdRr8.yDcWOi%2FjeAs2xhgQ%2Brrm6eEUWSpZp3YslrhATMDAtCs"], "body": ""}
{"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 &amp; 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": ""}
-61
View File
@@ -381,67 +381,6 @@
/org/acme/pulls/developers?type=your_repositories&sort=recentupdate&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE&search_mode=exact /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 /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 # Pages, raw files and edit pages of files, and the API's call for their
# contents. # contents.
/owner/repo/src/branch/main/package.json /owner/repo/src/branch/main/package.json
+3 -3
View File
@@ -42,9 +42,9 @@ const (
otherZone = "other.example" otherZone = "other.example"
// cacheTTL is the tests' SWW_WAF_IP_REPUTATION_CACHE_TTL, and timeout // cacheTTL is the tests' SWW_WAF_IP_REPUTATION_CACHE_TTL, and timeout
// their SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS: a second, the least time // 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 // /etc/resolv.conf
// it is the DNSBL's own timeout that ends a query, whatever that file // can have Go's resolver wait for one server, so that it is the
// says. // DNSBL's own timeout that ends a query, whatever that file says.
cacheTTL = 24 * time.Hour cacheTTL = 24 * time.Hour
timeout = time.Second timeout = time.Second
// listed and unlisted are clients zone is asked about by the names // listed and unlisted are clients zone is asked about by the names
+2 -137
View File
@@ -26,10 +26,6 @@ import (
// post is the start of a POST whose headers end with what follows it. // post is the start of a POST whose headers end with what follows it.
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n" const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
// limitPastFirstPart is a SWW_RPROXY_MAX_REQUEST_SIZE past the first part of a
// body, the 64 KiB rproxy reads before it asks waf.
const limitPastFirstPart = "128K"
// announcedLength is the header that announces a body of size bytes, and the // announcedLength is the header that announces a body of size bytes, and the
// end of the headers. // end of the headers.
func announcedLength(size int) string { func announcedLength(size int) string {
@@ -79,10 +75,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
size, asked int size, asked int
}{ }{
{"with the limit within the first part", "1K", 1025, 1024}, {"with the limit within the first part", "1K", 1025, 1024},
{ {"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
"with the limit past the first part", limitPastFirstPart, 129 << 10,
questionBodyMaxBytes,
},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -143,134 +136,6 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
} }
} }
func TestAVisitorSendingConnectionCloseReadsAnAnswerBeforeTheRestOfItsBody(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
name string
waf http.HandlerFunc
rest int
status int
}{
{"over the size limit", allow, 65 << 10, http.StatusRequestEntityTooLarge},
{"denied", deny, 65 << 10, http.StatusForbidden},
// rproxy reads 256 KiB of this rest. What it leaves unread must fit in the
// connection's buffers. Otherwise the visitor is still sending when the
// connection closes, and gets a reset.
{
"with more than 256 KiB left", allow, 272 << 10,
http.StatusRequestEntityTooLarge,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startWAF(t, tc.waf)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, _ := startRProxy(t, standIn.URL, app.URL,
map[string]string{maxRequestSize: limitPastFirstPart})
conn := dial(t, addr)
send(t, conn, post+"Connection: close\r\n"+
announcedLength(questionBodyMaxBytes+tc.rest)+
strings.Repeat("x", questionBodyMaxBytes))
reader := bufio.NewReader(conn)
res, err := http.ReadResponse(reader, nil)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
if res.StatusCode != tc.status {
t.Errorf("status %d, want %d", res.StatusCode, tc.status)
}
// The rest goes in small parts, so that a reset fails a later one.
for sent := 0; sent < tc.rest; sent += 1 << 10 {
send(t, conn, strings.Repeat("x", 1<<10))
}
got, err := io.ReadAll(reader)
if err != nil || len(got) != 0 {
t.Errorf(
"after the answer, read %q and %v, want the end",
got,
err,
)
}
})
}
}
func TestCloseDoesNotWaitForTheRestOfTheBodyOfAVisitorSendingConnectionClose(
t *testing.T,
) {
t.Parallel()
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionClose)
w.WriteHeader(http.StatusOK)
})
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
// The request time limit is far longer than dial's deadline.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "120000",
})
conn := dial(t, addr)
send(t, conn, post+"Connection: close\r\n"+
announcedLength(2*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes))
// If the connection stays open, dial's deadline ends this read with a timeout.
text, err := io.ReadAll(conn)
if err != nil || len(text) != 0 {
t.Errorf(
"read %q (%v), want the connection closed without an answer",
text,
err,
)
}
}
func TestAVisitorSendingConnectionCloseGetsTheEndOfAnAnswerWithoutContentLengthAtOnce(
t *testing.T,
) {
t.Parallel()
standIn := startWAF(t, allow)
// The flush has the app's server send greeting in chunks instead of adding a
// Content-Length.
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
rc := http.NewResponseController(w)
_ = rc.EnableFullDuplex()
_, _ = io.WriteString(w, greeting)
_ = rc.Flush()
})
// The request time limit is far longer than dial's deadline.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "120000",
})
conn := dial(t, addr)
send(t, conn, post+"Connection: close\r\n"+
announcedLength(2*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes+1))
wantGreeting(t, readAnswer(t, conn))
}
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) { func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -283,7 +148,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
mayReachApp bool mayReachApp bool
}{ }{
{"within the first part", "1K", 2 << 10, false}, {"within the first part", "1K", 2 << 10, false},
{"past the first part", limitPastFirstPart, 129 << 10, true}, {"past the first part", "128K", 129 << 10, true},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
+5 -34
View File
@@ -38,10 +38,6 @@ const plainText = "text/plain; charset=utf-8"
// and the visitor then gets a closed connection and no answer. // and the visitor then gets a closed connection and no answer.
const minSendTime = 10 * time.Second const minSendTime = 10 * time.Second
// maxBodyReadAfterAnswer is the most of a body left unread that Go's server
// reads once the handler returns, for a visitor without Connection: close.
const maxBodyReadAfterAnswer = 256 << 10
// errResponseTooLate refuses an app's answer that arrives once // errResponseTooLate refuses an app's answer that arrives once
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out. // SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
var errResponseTooLate = errors.New( var errResponseTooLate = errors.New(
@@ -343,8 +339,8 @@ func (rq *request) answer(status int) {
} }
// A visitor found too slow is read no more; any other may go on sending until // A visitor found too slow is read no more; any other may go on sending until
// its time is up, so that the rest of the body is read and the request ends // its time is up, so that Go's server can read the rest of the body and end
// cleanly: by Go's server, or by readRestOfBody. // the request cleanly.
deadline := rq.requestDeadline() deadline := rq.requestDeadline()
if status == http.StatusRequestTimeout { if status == http.StatusRequestTimeout {
deadline = time.Now() deadline = time.Now()
@@ -528,35 +524,10 @@ func (rq *request) stopReadingBody(deadline time.Time) {
_ = rq.rc.SetReadDeadline(deadline) _ = rq.rc.SetReadDeadline(deadline)
} }
// readRestOfBody reads up to 256 KiB of the rest of the body once the visitor
// has been answered with a Content-Length, until the read deadline already set on
// the connection. It does this only for a body with a declared length from a
// visitor that sent Connection: close, because Go's server reads none of that
// rest. Without the read, the visitor, still sending, gets a reset and may never
// read the answer. Go's server ends an answer without a Content-Length only once
// the handler returns, so reading first would hold back the end of that answer.
// Past 256 KiB, the MaxBytesReader made with Go's own ResponseWriter has Go's
// server tell the visitor it sends no more, then wait 500 ms before it closes
// the connection.
func (rq *request) readRestOfBody() {
if !rq.in.Close || rq.in.ContentLength <= 0 || rq.body.received.Load() ||
rq.out.Header().Get("Content-Length") == "" ||
rq.out.status == http.StatusSwitchingProtocols {
return
}
rest := http.MaxBytesReader(
rq.out.ResponseWriter,
rq.in.Body,
maxBodyReadAfterAnswer,
)
_, _ = io.Copy(io.Discard, rest)
}
// stopPassingOnBody ends the transport's reading of a visitor's body that has // stopPassingOnBody ends the transport's reading of a visitor's body that has
// not arrived whole, and lets Go's server, or readRestOfBody, read the rest until // not arrived whole, and lets Go's server read the rest until deadline. Go's
// deadline. Go's server reads it once the handler returns, but with no deadline // server reads it once the handler returns, but with no deadline at all if
// at all if another read of it is still waiting. // another read of it is still waiting.
func (rq *request) stopPassingOnBody(deadline time.Time) { func (rq *request) stopPassingOnBody(deadline time.Time) {
if rq.body == nil || rq.body.received.Load() { if rq.body == nil || rq.body.received.Load() {
return return
+2 -2
View File
@@ -110,7 +110,8 @@ func New(params Params) *Server {
// WaitForReports returns once the reports being sent have gone or failed, // WaitForReports returns once the reports being sent have gone or failed,
// which SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS bounds. Any report after this call // 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() { func (s *Server) WaitForReports() {
s.h.reportsMu.Lock() s.h.reportsMu.Lock()
s.h.reportsStopped = true s.h.reportsStopped = true
@@ -170,7 +171,6 @@ func newWAFTransport() *http.Transport {
// it writes the request's log line and sends its report. // it writes the request's log line and sends its report.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r) rq := h.newRequest(w, r)
defer rq.readRestOfBody()
defer rq.finish(r.Context()) defer rq.finish(r.Context())
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath { if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
+4 -4
View File
@@ -16,10 +16,10 @@ import (
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves // RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
// requests until ctx is done. Then it gives the requests in progress // requests until ctx is done. Then it gives the requests in progress
// shutdownTimeout to finish, and the reports being sent // shutdownTimeout to finish, and the reports being sent
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It returns the process's exit status, 1 // SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It
// when rproxy cannot start. Its process messages name the host's name as // returns the process's exit status, 1 when rproxy cannot start. Its process
// instance, and it writes those at info or more severe: rproxy has no // messages name the host's name as instance, and it writes those at info or
// setting for either. // more severe: rproxy has no setting for either.
func RunRProxy(ctx context.Context, params Params) int { func RunRProxy(ctx context.Context, params Params) int {
hostname, _ := os.Hostname() // "" when the host has no name to give hostname, _ := os.Hostname() // "" when the host has no name to give
processLog := requestlog.NewProcessLogger( processLog := requestlog.NewProcessLogger(
+3 -3
View File
@@ -34,8 +34,8 @@ const (
answerTimeLimit = 2 * time.Second answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS. // defaultLookupWait is the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS.
defaultLookupWait = 800 * time.Millisecond defaultLookupWait = 800 * time.Millisecond
// defaultDNSBLAbuseIPDBWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS. // defaultReputationWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
defaultDNSBLAbuseIPDBWait = 2 * time.Second defaultReputationWait = 2 * time.Second
// The header limits SPEC.md gives. // The header limits SPEC.md gives.
cookieLimit = 50 cookieLimit = 50
headerFieldLimit = 100 headerFieldLimit = 100
@@ -844,7 +844,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
// The query is answered, and the check with AbuseIPDB abandoned, // The query is answered, and the check with AbuseIPDB abandoned,
// before the bubble ends. // before the bubble ends.
releaseResolver() releaseResolver()
time.Sleep(defaultDNSBLAbuseIPDBWait) time.Sleep(defaultReputationWait)
}) })
} }
+5 -5
View File
@@ -20,8 +20,8 @@ type percentage struct {
// client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or // client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty, // SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty,
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is below 100, or // 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 // SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// before its request goes on. // then needed before its request goes on.
func biasedThresholdsSet(cfg *config.WAF) bool { func biasedThresholdsSet(cfg *config.WAF) bool {
return len(cfg.ASNBytesLimitPercent) > 0 || return len(cfg.ASNBytesLimitPercent) > 0 ||
len(cfg.CountryBytesLimitPercent) > 0 || len(cfg.CountryBytesLimitPercent) > 0 ||
@@ -70,10 +70,10 @@ func (rq *request) limitPercentages() (percentage, percentage) {
} }
} }
dnsblAbuseIPDBListed := percentage{percent: whole} reputationListed := percentage{percent: whole}
if (rq.dnsblListed || rq.abuseIPDBHit) && if (rq.dnsblListed || rq.abuseIPDBHit) &&
cfg.DNSBLAbuseIPDBAction == "limit" { cfg.DNSBLAbuseIPDBAction == "limit" {
dnsblAbuseIPDBListed = percentage{ reputationListed = percentage{
cfg.DNSBLAbuseIPDBLimitPercent, cfg.DNSBLAbuseIPDBLimitPercent,
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION", "SWW_WAF_DNSBL_ABUSEIPDB_ACTION",
} }
@@ -120,7 +120,7 @@ func (rq *request) limitPercentages() (percentage, percentage) {
} }
// The limits that lower requests and bytes alike. // The limits that lower requests and bytes alike.
common := lowest(otherTags, unknown, blocklisted, dnsblAbuseIPDBListed) common := lowest(otherTags, unknown, blocklisted, reputationListed)
return lowest(asnRequests, countryRequests, common), return lowest(asnRequests, countryRequests, common),
lowest(asnBytes, countryBytes, common) lowest(asnBytes, countryBytes, common)
+1 -1
View File
@@ -617,7 +617,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
} }
} }
func TestDNSBLAbuseIPDBLimitTakesPartInTheLowestPercentageOfEveryLimit( func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()