Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79a2a00722 |
No files matched your search
+16
-25
@@ -87,7 +87,7 @@ COPY . .
|
||||
# .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
|
||||
# tag keeps Coraza from writing the files of a multipart body to the
|
||||
# system's temporary directory, since sww writes only to its state
|
||||
# system's temporary directory, since smallwebwaf writes only to its state
|
||||
# directory.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
@@ -99,7 +99,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
fi; \
|
||||
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /usr/local/bin/sww ./cmd/sww
|
||||
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
|
||||
|
||||
# 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
|
||||
@@ -118,7 +118,7 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
||||
-o /usr/local/bin/runsvinit .
|
||||
|
||||
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
||||
# SWW_COMMAND says discriminator, and an app's Dockerfile can build FROM it.
|
||||
# SMALLWEBWAF_COMMAND says servewaf, and an app's Dockerfile can build FROM it.
|
||||
# It is the last stage, so a plain `docker build .` builds it.
|
||||
#
|
||||
# ubuntu 26.04, 2026-09-27
|
||||
@@ -136,19 +136,10 @@ RUN mkdir /etc/sysusers.d \
|
||||
# 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
|
||||
# package lists, so no CA certificates are needed.
|
||||
#
|
||||
# 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 \
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||
ca-certificates nix-bin runit \
|
||||
&& 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
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 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.
|
||||
@@ -175,27 +166,27 @@ RUN curl -fsSL -o /tmp/nixexprs.tar.xz \
|
||||
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
||||
|
||||
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
||||
COPY --from=builder /usr/local/bin/sww /usr/local/bin/sww
|
||||
COPY --from=builder /usr/local/bin/smallwebwaf /usr/local/bin/smallwebwaf
|
||||
|
||||
# 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.
|
||||
RUN groupadd --system --gid 65532 sww \
|
||||
RUN groupadd --system --gid 65532 smallwebwaf \
|
||||
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
||||
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
||||
sww
|
||||
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \
|
||||
smallwebwaf
|
||||
|
||||
# The state files' directory, SWW_STATE_DIR by default, where a volume
|
||||
# The state files' directory, SWWAF_STATE_DIR by default, where a volume
|
||||
# is mounted to keep them across deploys. The run script gives it to the
|
||||
# sww user at each start of discriminator.
|
||||
RUN mkdir /var/lib/sww
|
||||
# smallwebwaf user at each start of servewaf.
|
||||
RUN mkdir /var/lib/smallwebwaf
|
||||
|
||||
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
||||
# The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile
|
||||
# built FROM this image can copy rule files of its own beside it.
|
||||
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
||||
COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules
|
||||
|
||||
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
||||
# looks too.
|
||||
COPY --chmod=755 share/sww.run /etc/service/sww/run
|
||||
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -203,6 +194,6 @@ EXPOSE 8080
|
||||
# check runs every second from the start until it first passes, for up
|
||||
# to a minute, and every 30 seconds after that.
|
||||
HEALTHCHECK --start-period=1m --start-interval=1s \
|
||||
CMD ["/usr/local/bin/sww", "healthcheck"]
|
||||
CMD ["/usr/local/bin/smallwebwaf", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
||||
+46
-58
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
|
||||
- R1: rate limits per minute, per hour and per day
|
||||
- R2: netblocks that bypass rate limiting
|
||||
- R3: alerts on attacks through webhook, Slack or ntfy
|
||||
- R4: alerts on anomalies: requests made or bytes transferred per minute or
|
||||
hour, for given IPs or netblocks, crossing a threshold
|
||||
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs
|
||||
or netblocks, crossing a threshold
|
||||
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
||||
- R6: one or more RBLs or IP reputation APIs
|
||||
- R7: AS number lookup
|
||||
@@ -24,19 +24,14 @@ configured only by environment variables, that provides:
|
||||
|
||||
## Verdict
|
||||
|
||||
Nothing existing is suitable. No surveyed tool meets R8, which asks for limits
|
||||
scaled by AS number or country. None meets R4's alerts on bytes transferred. The
|
||||
tools that come closest on the rest fail R10: they need several containers, YAML
|
||||
or a web UI. The recommendation is to build a small single-binary sidecar;
|
||||
`SPEC.md` in this directory begins that spec.
|
||||
|
||||
The sidecar reuses the mature parts of the surveyed field instead of
|
||||
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
|
||||
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
|
||||
number or country) or the bytes half of R4 at all, and the tools that come
|
||||
closest on the rest fail R10: they need several containers, YAML or a web UI.
|
||||
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
|
||||
directory begins that spec. It reuses the mature parts of the surveyed field as
|
||||
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
|
||||
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of
|
||||
reimplementing them.
|
||||
|
||||
Closest existing options, and why each still falls short:
|
||||
|
||||
@@ -50,16 +45,13 @@ Closest existing options, and why each still falls short:
|
||||
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
|
||||
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
||||
Set, or Coraza plugin), env-var settings.
|
||||
- Fails:
|
||||
- R8: AS number and country can only allow or deny, never scale a limit.
|
||||
- R4: no alerts on request-count or byte thresholds in the free edition;
|
||||
reporting is a paid feature.
|
||||
- R5: no escalation for repeat offenders.
|
||||
- R10: since 1.6 it is not one plain sidecar. It needs a `bunkerweb`
|
||||
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.
|
||||
- Fails: R8 (AS number and country can only allow or deny, never scale a
|
||||
limit), R4 (no volume or byte threshold alerts in the free edition;
|
||||
reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6
|
||||
it needs a `bunkerweb` container plus a `bw-scheduler` container and a
|
||||
database, or the all-in-one image that bundles nginx, scheduler, UI and
|
||||
Redis in one container. It is designed to be the front door for many
|
||||
sites, not a per-app sidecar. AGPL-3.0.
|
||||
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
||||
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.
|
||||
@@ -74,20 +66,16 @@ Closest existing options, and why each still falls short:
|
||||
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
|
||||
answers 429.
|
||||
- Fails:
|
||||
- R10, because it needs:
|
||||
- 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.
|
||||
- Fails: R10 (needs the security engine container with persistent state, log
|
||||
acquisition from traefik, a bouncer such as the traefik plugin, and YAML
|
||||
for acquisition, profiles, scenarios and notifications), bytes half of R4.
|
||||
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
||||
with no new code. It gives inline limiting (one window per middleware, keyed
|
||||
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
||||
reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely:
|
||||
the configuration lives in traefik labels and CrowdSec YAML on each host, not
|
||||
in one sidecar's environment. It meets R1's three windows only by chaining
|
||||
three middlewares per router.
|
||||
reputation. It still fails R8, the bytes half of R4, R1's three stacked
|
||||
windows only by chaining three middlewares per router, and R10 entirely: the
|
||||
configuration lives in traefik labels and CrowdSec YAML on each host, not in
|
||||
one sidecar's environment.
|
||||
|
||||
## Candidate by candidate
|
||||
|
||||
@@ -99,10 +87,10 @@ Closest existing options, and why each still falls short:
|
||||
cached, streamed decision list (recommended), standalone against the
|
||||
central API, or AppSec only. Supports captcha remediation.
|
||||
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
||||
- Misses: the plugin itself does no rate limiting; R8; R4's alerts on bytes.
|
||||
- Misses: the plugin itself does no rate limiting; R8; R4 bytes.
|
||||
- Configuration: traefik static config to load the plugin, dynamic config or
|
||||
labels for the middleware, CrowdSec YAML for everything else.
|
||||
- Sidecar fit: no. It runs inside traefik and needs a separate engine
|
||||
- Sidecar fit: no. It lives inside traefik, plus a separate engine
|
||||
container.
|
||||
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
||||
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
||||
@@ -127,7 +115,7 @@ Closest existing options, and why each still falls short:
|
||||
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
||||
semantic detection engine and a web console. About 22,000 stars.
|
||||
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
||||
challenge, authentication in front of the app.
|
||||
challenge, authentication gate.
|
||||
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
||||
messages, syslog forwarding and the better reputation database are paid
|
||||
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
||||
@@ -140,7 +128,7 @@ Closest existing options, and why each still falls short:
|
||||
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
||||
- What it is: a Go library that implements the ModSecurity rule language and
|
||||
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
||||
successor to ModSecurity, which is now in maintenance only.
|
||||
successor path now that ModSecurity is in maintenance only.
|
||||
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
||||
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
||||
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
||||
@@ -154,7 +142,7 @@ Closest existing options, and why each still falls short:
|
||||
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
||||
- 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`,
|
||||
paranoia level, anomaly thresholds, many more). Current images contain
|
||||
paranoia level, anomaly thresholds, many more). Current images carry
|
||||
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
||||
- Covers: R9, and R10 (single container, env vars, one backend).
|
||||
- Misses: R1 to R8.
|
||||
@@ -164,16 +152,16 @@ Closest existing options, and why each still falls short:
|
||||
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
||||
- What it is: a single-binary reverse proxy that makes browsers solve a
|
||||
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
||||
scrapers, which are likely a large share of the unwanted traffic on a
|
||||
public code forge.
|
||||
scrapers, which is likely a large share of unwanted traffic on a public
|
||||
gitea.
|
||||
- Covers: R10 well (one container, env vars for listener, target,
|
||||
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
|
||||
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
|
||||
vars. AS number and country matching depend on the vendor's hosted
|
||||
service. Breaks non-browser clients unless paths are exempted; for a code
|
||||
forge, git-over-HTTP and API paths must be allowed through by rule.
|
||||
service. Breaks non-browser clients unless paths are exempted; for gitea,
|
||||
git-over-HTTP and API paths must be allowed through by rule.
|
||||
- Maturity: very active, widely deployed on code forges since 2025.
|
||||
- Value here: complementary. It can be chained (traefik, then the sidecar,
|
||||
then Anubis, then the app) if challenge pages are wanted.
|
||||
@@ -192,13 +180,13 @@ Closest existing options, and why each still falls short:
|
||||
temporary ban, no escalation, state lost on traefik restart) and R2
|
||||
(allowlist). Nothing else.
|
||||
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
||||
plugins (country allow or deny through an external lookup API): each adds
|
||||
one small feature, is configured in traefik and keeps its state in memory.
|
||||
plugins (country allow or deny through an external lookup API): same
|
||||
shape, each one small feature, configured in traefik, state in memory.
|
||||
- Sidecar fit: no; they live inside traefik.
|
||||
- Traefik built-in middlewares
|
||||
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
||||
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
||||
lookups or AS number lookups.
|
||||
or AS number awareness.
|
||||
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
||||
- 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
|
||||
@@ -210,12 +198,12 @@ Closest existing options, and why each still falls short:
|
||||
- open-appsec (Check Point)
|
||||
- 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;
|
||||
rate limiting and richer features are in paid tiers. It attaches to an
|
||||
existing proxy, so it is not the sidecar R10 asks for.
|
||||
rate limiting and richer features are in paid tiers. Not a sidecar in the
|
||||
required sense.
|
||||
- iocaine
|
||||
- Serves generated garbage pages to clients the fronting proxy classifies as
|
||||
scrapers. Not a limiter, WAF or ban tool; out of scope, and only of
|
||||
passing interest for scraper traffic.
|
||||
scrapers. Not a limiter, WAF or ban tool; out of scope except as a
|
||||
curiosity for scraper traffic.
|
||||
- Pangolin
|
||||
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
||||
Replaces the ingress rather than adding a sidecar; out of scope.
|
||||
@@ -226,17 +214,17 @@ Closest existing options, and why each still falls short:
|
||||
Everyone else offers one window per rule or none.
|
||||
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
||||
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
||||
- R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts
|
||||
on request counts with a custom scenario; no surveyed tool alerts on bytes.
|
||||
- R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one
|
||||
fixed length or permanent, and does not change with a client's history.
|
||||
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count
|
||||
anomalies with a custom scenario; nothing handles bytes.
|
||||
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent,
|
||||
not both by history.
|
||||
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
||||
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
||||
deny), Anubis (hosted service).
|
||||
- R8 biased thresholds: none.
|
||||
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
||||
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza
|
||||
or ModSecurity).
|
||||
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the
|
||||
first).
|
||||
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
||||
None of these covers anything beyond its one job.
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
|
||||
hooks build run example-app
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||
run example-app gitea-requests gitea-setup
|
||||
|
||||
# Makefile targets are thin shims; the implementations live in script/
|
||||
# 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,
|
||||
# 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; publish builds
|
||||
# the image on the Docker host and pushes it to the registry.
|
||||
# example-app checks the image with an app built on it; gitea-requests
|
||||
# reports the gitea requests the Core Rule Set refuses at the defaults;
|
||||
# gitea-setup checks the setup in deploy/gitea.
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
@@ -35,9 +36,6 @@ check:
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
publish:
|
||||
@script/publish
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
@@ -49,3 +47,9 @@ run:
|
||||
|
||||
example-app:
|
||||
@script/example-app
|
||||
|
||||
gitea-requests:
|
||||
@script/gitea-requests
|
||||
|
||||
gitea-setup:
|
||||
@script/gitea-setup
|
||||
@@ -7,29 +7,29 @@ requests of scrapers, scanners and other abusers before they reach an app.
|
||||
## What it does
|
||||
|
||||
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 program, `sww`,
|
||||
shipped in one image, with two commands that do this together:
|
||||
refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in
|
||||
one image, with two commands that do this together:
|
||||
|
||||
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
|
||||
it. It asks `discriminator` about each request, passes the allowed ones to the
|
||||
app, and passes the app's answers back. It keeps nothing and never touches
|
||||
disk.
|
||||
- `sww discriminator` decides. It runs the checks and keeps the bans, the
|
||||
counters and each client's history. One `discriminator` serves many sites: a
|
||||
client banned through one site is refused on all of them, and its requests on
|
||||
all of them count toward one set of limits.
|
||||
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik
|
||||
routes to it. It asks `servewaf` about each request, passes the allowed ones
|
||||
to the app, and passes the app's answers back. It keeps nothing and never
|
||||
touches disk.
|
||||
- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the
|
||||
counters and each client's history. One `servewaf` serves many sites: a client
|
||||
banned through one site is refused on all of them, and its requests on all of
|
||||
them count toward one set of limits.
|
||||
|
||||
The site keeps answering while `discriminator` is down, slow or restarting:
|
||||
without an answer in time, `rproxy` passes the request to the app.
|
||||
The site keeps answering while `servewaf` is down, slow or restarting: without
|
||||
an answer in time, `rproxy` passes the request to the app.
|
||||
|
||||
It protects against:
|
||||
|
||||
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
||||
requests and on body bytes per minute, per hour and per day. The defaults
|
||||
leave room for a person reading a site for hours. They catch a scraper that
|
||||
keeps up one request every 2 seconds. A client that breaks a limit is banned:
|
||||
for a while at first, for three times as long at each repeat, and permanently
|
||||
when it keeps at it.
|
||||
requests and on bytes per minute, per hour and per day. The defaults leave
|
||||
room for a person reading a site for hours. They catch a scraper that keeps up
|
||||
one request every 2 seconds. A client over a limit is refused and banned: for
|
||||
a while at first, for three times as long at each repeat, and permanently when
|
||||
it keeps at it.
|
||||
- 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
|
||||
during the ban makes the ban permanent. Your own rules, or a list of trap
|
||||
@@ -68,9 +68,8 @@ Not built, and not planned:
|
||||
|
||||
### The image
|
||||
|
||||
The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
|
||||
from a clone. `make docker` runs the tests and the linter, then builds the
|
||||
image, tagged `smallwebwaf`.
|
||||
No image is published. Build it from a clone. `make docker` runs the tests and
|
||||
the linter, then builds the image, tagged `smallwebwaf`.
|
||||
|
||||
```sh
|
||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||
@@ -80,11 +79,11 @@ make docker
|
||||
|
||||
Push the image to a registry your hosts pull from, and name it there by digest.
|
||||
|
||||
The image runs `rproxy` by default. To run `discriminator` from the same image,
|
||||
set the container's `SWW_COMMAND` to `discriminator`. The image's health check
|
||||
The image runs `rproxy` by default. To run `servewaf` from the same image, set
|
||||
the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check
|
||||
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
|
||||
the app accepts connections, whether or not `discriminator` answers; in
|
||||
`discriminator`'s container, once `discriminator` answers.
|
||||
the app accepts connections, whether or not `servewaf` answers; in `servewaf`'s
|
||||
container, once `servewaf` answers.
|
||||
|
||||
### The usual setup: three containers
|
||||
|
||||
@@ -92,24 +91,24 @@ The usual setup is three containers on one private network:
|
||||
|
||||
- the app;
|
||||
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
||||
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
|
||||
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
|
||||
`/var/lib/sww` for its state files.
|
||||
names `servewaf` and `SWRP_UPSTREAM_URL` names the app;
|
||||
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at
|
||||
`/var/lib/smallwebwaf` for its state files.
|
||||
|
||||
Point traefik's labels at port 8080 of `rproxy`
|
||||
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
||||
of the app or of `discriminator`. Anything on that network can reach the app
|
||||
around `rproxy`, and can ask `discriminator` questions, so the network holds
|
||||
only what you trust. Each further app adds its own container and its own
|
||||
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
|
||||
setup" example below shows this shape.
|
||||
of the app or of `servewaf`. Anything on that network can reach the app around
|
||||
`rproxy`, and can ask `servewaf` questions, so the network holds only what you
|
||||
trust. Each further app adds its own container and its own `rproxy`, whose
|
||||
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" and "In front
|
||||
of gitea" examples below show this shape.
|
||||
|
||||
### `rproxy` in the app's container
|
||||
|
||||
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
|
||||
runs beside the app in one container. `discriminator` still runs in a container
|
||||
of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
|
||||
by digest, and adds:
|
||||
runs beside the app in one container. `servewaf` still runs in a container of
|
||||
its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by
|
||||
digest, and adds:
|
||||
|
||||
- the app's binary;
|
||||
- the packages the app needs, from the nixpkgs in the image;
|
||||
@@ -169,16 +168,16 @@ does.
|
||||
- A log line with `"msg":"starting"` gives the version, the address and every
|
||||
setting in effect, with secrets masked.
|
||||
- A request to the site through traefik gets the app's answer.
|
||||
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
|
||||
the request have `"action":"forward"`.
|
||||
- To see a refusal without banning yourself, run `discriminator` with
|
||||
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||
`discriminator`'s log lines have `"would_action":"temporarily_banned"` and
|
||||
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines
|
||||
about the request have `"action":"forward"`.
|
||||
- To see a refusal without banning yourself, run `servewaf` with
|
||||
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||
`servewaf`'s log lines have `"would_action":"banned"` and
|
||||
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
||||
"Clients and bans" below describes.
|
||||
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||
`discriminator`'s network:
|
||||
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
|
||||
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||
`servewaf`'s network:
|
||||
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`.
|
||||
- `make example-app` builds the image and an app on it, runs them, and checks
|
||||
the whole exchange (see "Entrypoints").
|
||||
|
||||
@@ -186,17 +185,16 @@ does.
|
||||
|
||||
Every setting is an environment variable, read once at start; changing one means
|
||||
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
||||
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
||||
`servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
||||
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.
|
||||
|
||||
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
|
||||
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
|
||||
contents are the value, less one newline at the end. Setting both forms stops
|
||||
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
|
||||
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
|
||||
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
|
||||
one, since `rproxy` never touches disk.
|
||||
- Any `SWWAF_` setting can be given as a file instead, for secrets and long
|
||||
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
|
||||
The file's contents are the value, less one newline at the end. Setting both
|
||||
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already names a file, so
|
||||
it has no `_FILE` form, and 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 size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
||||
1024: `512K`, `100M`, `5G`.
|
||||
@@ -210,133 +208,127 @@ 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 `/`.
|
||||
- `off` switches a limit, a threshold or a timeout off where the table says a
|
||||
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
|
||||
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 `********`.
|
||||
|
||||
Every setting the code reads, with its default:
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
||||
| `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_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_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 `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_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` | 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`. |
|
||||
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
||||
| `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`. |
|
||||
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
||||
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
||||
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||
| `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. |
|
||||
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
||||
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||
| `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. |
|
||||
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||
| `SWW_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`. |
|
||||
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||
| `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. |
|
||||
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
|
||||
| `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. |
|
||||
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||
| `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). |
|
||||
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||
| `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. |
|
||||
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||
| `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`. |
|
||||
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||
| `SWW_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_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||
| `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. |
|
||||
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||
| `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`. |
|
||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `SWW_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_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_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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||
| `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. |
|
||||
| Variable | Default | What it does |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. |
|
||||
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf: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_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_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `servewaf`'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_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_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_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. |
|
||||
| `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`. |
|
||||
| `SWWAF_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. |
|
||||
| `SWWAF_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. |
|
||||
| `SWWAF_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. |
|
||||
| `SWWAF_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. |
|
||||
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, 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. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
||||
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
||||
| `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. |
|
||||
| `SWWAF_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. |
|
||||
| `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. |
|
||||
| `SWWAF_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. |
|
||||
| `SWWAF_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`. |
|
||||
| `SWWAF_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. |
|
||||
| `SWWAF_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`. |
|
||||
| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||
| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
||||
| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
||||
| `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`. |
|
||||
| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. |
|
||||
| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||
| `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. |
|
||||
| `SWWAF_RULES_DIR` | `/etc/smallwebwaf/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||
| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||
| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||
| `SWWAF_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`. |
|
||||
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||
| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||
| `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. |
|
||||
| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||
| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||
| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||
| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||
| `SWWAF_STATE_DIR` | `/var/lib/smallwebwaf` | The directory of the state files, an absolute path. |
|
||||
| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||
| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||
| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||
| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||
| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||
| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_smallwebwaf/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||
| `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`. |
|
||||
| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||
| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||
| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||
| `SWWAF_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_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||
| `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. |
|
||||
| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||
| `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`. |
|
||||
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||
| `SWWAF_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_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
||||
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, 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. |
|
||||
| `SWWAF_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_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
||||
| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, 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. |
|
||||
| `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. |
|
||||
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||
| `SWWAF_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_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
||||
| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
||||
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||
| `SWWAF_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_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
||||
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, 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`. |
|
||||
| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||
| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||
| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
||||
| `SWWAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
||||
|
||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||
full, and the limits that are fixed rather than settings.
|
||||
@@ -346,13 +338,14 @@ full, and the limits that are fixed rather than settings.
|
||||
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
|
||||
under, by digest. Docker Compose puts the services on a private network of its
|
||||
own, where each reaches the others by service name. Each example publishes
|
||||
`rproxy`'s port on the host, for trying it out.
|
||||
own, where each reaches the others by service name. Only "In front of gitea"
|
||||
uses traefik. The other three publish `rproxy`'s port on the host, for trying it
|
||||
out.
|
||||
|
||||
### Smallest setup
|
||||
|
||||
One app, `rproxy` in front of it, and `discriminator` with a volume for its
|
||||
state files, every setting at its default.
|
||||
One app, `rproxy` in front of it, and `servewaf` with a volume for its state
|
||||
files, every setting at its default.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -363,20 +356,20 @@ services:
|
||||
rproxy:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
SWRP_UPSTREAM_URL: http://app:8081
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
discriminator:
|
||||
servewaf:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWW_COMMAND: discriminator
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
volumes:
|
||||
- discriminator-state:/var/lib/sww
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
|
||||
volumes:
|
||||
discriminator-state:
|
||||
servewaf-state:
|
||||
```
|
||||
|
||||
### Custom per-address limits
|
||||
@@ -399,45 +392,128 @@ services:
|
||||
rproxy:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
SWRP_UPSTREAM_URL: http://app:8081
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
discriminator:
|
||||
servewaf:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWW_COMMAND: discriminator
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
# Monitoring skips every check.
|
||||
SWW_ALLOW_NETS: 192.0.2.10
|
||||
SWWAF_ALLOW_NETS: 192.0.2.10
|
||||
# The office and the automation host are never rate limited.
|
||||
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||
# A netblock that sends nothing wanted.
|
||||
SWW_DENY_NETS: 198.51.100.128/25
|
||||
SWWAF_DENY_NETS: 198.51.100.128/25
|
||||
# Tighter limits for everyone else: a small static site.
|
||||
SWW_RATE_LIMIT_PER_MINUTE: "60"
|
||||
SWW_RATE_LIMIT_PER_HOUR: "600"
|
||||
SWW_RATE_LIMIT_PER_DAY: "3000"
|
||||
SWWAF_RATE_LIMIT_PER_MINUTE: "60"
|
||||
SWWAF_RATE_LIMIT_PER_HOUR: "600"
|
||||
SWWAF_RATE_LIMIT_PER_DAY: "3000"
|
||||
# Hosting networks and one country get a share of those limits.
|
||||
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
|
||||
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
||||
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
|
||||
# Paths only scanners ask for; one request bans the client.
|
||||
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||
# Answer a refused client with 429 rather than 403.
|
||||
SWW_BAN_RESPONSE: "429"
|
||||
SWW_LIMIT_BAN_DURATION: 30m
|
||||
SWWAF_BAN_RESPONSE: "429"
|
||||
SWWAF_LIMIT_BAN_DURATION: 30m
|
||||
volumes:
|
||||
- discriminator-state:/var/lib/sww
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
|
||||
volumes:
|
||||
discriminator-state:
|
||||
servewaf-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
|
||||
|
||||
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
|
||||
container". `rproxy` answers `503` to every request while `discriminator` is
|
||||
down, and `discriminator`:
|
||||
container". `rproxy` answers `503` to every request while `servewaf` is down,
|
||||
and `servewaf`:
|
||||
|
||||
- observes before it enforces;
|
||||
- looks addresses up in a local database rather than GeoJS;
|
||||
@@ -450,46 +526,46 @@ services:
|
||||
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
||||
build: ./app
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||
# Answer 503 rather than pass a request on while discriminator is down.
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
# Answer 503 rather than pass a request on while servewaf is down.
|
||||
SWRP_DEFAULT_DISPOSITION: deny
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
discriminator:
|
||||
servewaf:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWW_COMMAND: discriminator
|
||||
SWW_INSTANCE_NAME: fsn1/discriminator
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
SWWAF_INSTANCE_NAME: fsn1/servewaf
|
||||
# Refuse nothing yet; log what enforce mode would have done.
|
||||
SWW_MODE: observe
|
||||
SWWAF_MODE: observe
|
||||
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
||||
SWW_LOOKUP_SOURCE: file
|
||||
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
||||
SWWAF_LOOKUP_SOURCE: file
|
||||
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
||||
# Alerts to an ntfy topic, with its token read from a file.
|
||||
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
||||
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
||||
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
|
||||
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
||||
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy
|
||||
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
||||
volumes:
|
||||
- discriminator-state:/var/lib/sww
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
- /srv/ipinfo:/var/lib/ipinfo:ro
|
||||
- /srv/sww/tokens:/etc/sww/tokens:ro
|
||||
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro
|
||||
|
||||
volumes:
|
||||
discriminator-state:
|
||||
servewaf-state:
|
||||
```
|
||||
|
||||
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 `sww` user, with mode
|
||||
`0400`. This one holds an access token the ntfy server issued, which
|
||||
database is seen. A token file belongs to uid 65532, the `smallwebwaf` user,
|
||||
with mode `0400`. This one holds an access token the ntfy server issued, which
|
||||
`ntfy token add <user>` prints there:
|
||||
|
||||
```sh
|
||||
mkdir -p /srv/sww/tokens
|
||||
mkdir -p /srv/smallwebwaf/tokens
|
||||
# <token> is the access token that ntfy printed.
|
||||
echo '<token>' > /srv/sww/tokens/ntfy
|
||||
chown 65532:65532 /srv/sww/tokens/ntfy
|
||||
chmod 0400 /srv/sww/tokens/ntfy
|
||||
echo '<token>' > /srv/smallwebwaf/tokens/ntfy
|
||||
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy
|
||||
chmod 0400 /srv/smallwebwaf/tokens/ntfy
|
||||
```
|
||||
|
||||
## Design
|
||||
@@ -498,58 +574,54 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
|
||||
|
||||
### What happens to a request
|
||||
|
||||
A request goes through these steps, in this order. Once `discriminator` refuses
|
||||
a request, the steps after that are skipped, apart from the report, and nothing
|
||||
of the request reaches the app.
|
||||
A request goes through these steps, in this order. Once `servewaf` refuses a
|
||||
request, the steps after that are skipped, apart from the report, and nothing of
|
||||
the request reaches the app.
|
||||
|
||||
`rproxy` takes the first two:
|
||||
|
||||
1. The health check, `GET /_sww/healthz`, is answered at once.
|
||||
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
|
||||
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
|
||||
the visitor sent it, with the first 64 KiB of its body.
|
||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `servewaf` the request as the
|
||||
visitor sent it, with the first 64 KiB of its body.
|
||||
|
||||
`discriminator` takes the checks:
|
||||
`servewaf` takes the checks:
|
||||
|
||||
3. The tag lists.
|
||||
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||
5. The bans.
|
||||
6. The lookup of the client's AS number and country.
|
||||
7. The country list.
|
||||
8. The reputation sources.
|
||||
9. The tag refusals of `SWW_TAG_DENY`.
|
||||
10. The client's limit percentage, from its tags, its AS number, its country or
|
||||
a reputation hit.
|
||||
11. The rate limits.
|
||||
12. The trap paths.
|
||||
13. The rule files.
|
||||
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.
|
||||
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`.
|
||||
4. The bans.
|
||||
5. The lookup of the client's AS number and country.
|
||||
6. The country lists.
|
||||
7. The reputation sources.
|
||||
8. The client's limit percentage, from its AS number, its country or a
|
||||
reputation hit.
|
||||
9. The rate limits.
|
||||
10. The trap paths.
|
||||
11. The rule files.
|
||||
12. The header limits, and then the Core Rule Set.
|
||||
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
|
||||
that prefix.
|
||||
|
||||
`rproxy` takes the rest, with `discriminator` counting at the end:
|
||||
`rproxy` takes the rest, with `servewaf` counting at the end:
|
||||
|
||||
17. The request is passed to the app, streaming.
|
||||
18. After the answer, `rproxy` sends `discriminator` a report with the status
|
||||
and the body bytes each way. For an allowed request, `discriminator` counts
|
||||
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.
|
||||
14. The request is passed to the app, streaming.
|
||||
15. After the answer, `rproxy` sends `servewaf` a report with the status and the
|
||||
bytes each way. For an allowed request, `servewaf` counts the bytes toward
|
||||
the byte limits and adds the request to the client's history.
|
||||
|
||||
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
||||
request over the header limits of step 14 is answered `431`, and bans nobody
|
||||
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
||||
status for a size or time limit, such as `413` for a body too large.
|
||||
A refused visitor is answered with `SWWAF_BAN_RESPONSE`. A `block` rule or a
|
||||
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
||||
false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD`
|
||||
instead. A request over the header limits of step 12 is answered `431`, and bans
|
||||
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
|
||||
and the status for a size or time limit, such as `413` for a body too large.
|
||||
|
||||
### Clients and bans
|
||||
|
||||
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
|
||||
since one abuser usually controls a whole /64.
|
||||
- A client is one IPv4 address or one IPv6 netblock of
|
||||
`SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
|
||||
- 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
|
||||
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
|
||||
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too.
|
||||
- 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
|
||||
lifts it.
|
||||
@@ -558,54 +630,28 @@ 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
|
||||
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
|
||||
|
||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In
|
||||
front of a code forge, bodies carry what people write and publish, which the
|
||||
rules take for attacks.
|
||||
- `smallwebwaf` changes the Core Rule Set in nine ways so that ordinary requests
|
||||
get through to a code forge and to WordPress. They include git over HTTP and
|
||||
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
|
||||
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
|
||||
requests to a code forge through, such as git over HTTP and sign-in through
|
||||
Git Credential Manager. No setting undoes them; "Attack detection" in
|
||||
[`SPEC.md`](SPEC.md) lists them.
|
||||
- These apps work out of the box at the default settings, and the Core Rule Set
|
||||
refuses none of their ordinary requests:
|
||||
- gitea;
|
||||
- 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
|
||||
while `discriminator` runs. The image ships one file of probes no real visitor
|
||||
while `servewaf` runs. The image ships one file of probes no real visitor
|
||||
sends (see [`docs/rules.md`](docs/rules.md)).
|
||||
|
||||
### State files
|
||||
|
||||
`discriminator` keeps everything in memory, and the five JSON files in
|
||||
`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
||||
`servewaf` keeps everything in memory, and the five JSON files in
|
||||
`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
||||
`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. `discriminator`
|
||||
notices a file edited on disk and takes the edit in; an edit that does not parse
|
||||
is set aside as `<name>.bad`, and never stops the running process (see
|
||||
renamed over the real one, so a crash leaves a complete file. `servewaf` notices
|
||||
a file edited on disk and takes the edit in; an edit that does not parse is set
|
||||
aside as `<name>.bad`, and never stops the running process (see
|
||||
[`docs/state-files.md`](docs/state-files.md)).
|
||||
|
||||
### The image and its processes
|
||||
@@ -614,34 +660,33 @@ is set 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
|
||||
`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)).
|
||||
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
|
||||
and starts it again when it exits. The `run` script of `discriminator` gives
|
||||
the state directory to the `sww` user at each start, so a volume needs no
|
||||
change of owner.
|
||||
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own
|
||||
endpoints, under `/_sww/`, are for your admin tools and your metrics
|
||||
collector, at its own address on the private network; nothing routes the
|
||||
internet there.
|
||||
- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid
|
||||
and gid 65532) and starts it again when it exits. The `run` script of
|
||||
`servewaf` gives the state directory to the `smallwebwaf` user at each start,
|
||||
so a volume needs no change of owner.
|
||||
- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints,
|
||||
under `/_smallwebwaf/`, are for your admin tools and your metrics 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`
|
||||
in the app's container, the app must keep answering for five seconds after
|
||||
`SIGTERM` for that to help them.
|
||||
- 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
|
||||
address it cannot listen on. Once running, a failing lookup service,
|
||||
reputation source or alert destination never takes `discriminator` down.
|
||||
reputation source or alert destination never takes `servewaf` down.
|
||||
|
||||
### Packages
|
||||
|
||||
- `cmd/sww`: the binary, which only calls `internal/sww`.
|
||||
- `internal/sww`: the process, as the command it is given, and the health check.
|
||||
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
||||
- `internal/smallwebwaf`: the process, as the command it is given, and the
|
||||
health check.
|
||||
- `internal/config`: reads the settings, the one place they are read.
|
||||
- `internal/rproxy`: the reverse proxy in front of one app.
|
||||
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
|
||||
answers its own endpoints.
|
||||
- `internal/ratelimit`, `internal/bans`, `internal/rules`,
|
||||
`internal/coreruleset`, `internal/lookup`, `internal/reputation`,
|
||||
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
|
||||
source each.
|
||||
- `internal/servewaf`: `servewaf`'s server, which runs the checks and answers
|
||||
its own endpoints.
|
||||
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
||||
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
|
||||
`internal/alerts`: one check or source each.
|
||||
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
||||
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
||||
metrics.
|
||||
@@ -698,42 +743,46 @@ on the host.
|
||||
`make check`.
|
||||
- `script/docker`: builds the image, whose build runs the tests and the linter
|
||||
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
|
||||
image build.
|
||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||
- `script/install-precommit`: installs that hook. `make hooks`.
|
||||
- `script/build`: builds `bin/sww` with the Go on your host. `make build`.
|
||||
- `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
|
||||
- `script/build`: builds `bin/smallwebwaf` with the Go on your host.
|
||||
`make build`.
|
||||
- `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the
|
||||
host, with the settings in your environment. `make run`. Unless your settings
|
||||
say otherwise:
|
||||
- `rproxy` listens on port 8080, in front of an app at
|
||||
`http://127.0.0.1:8081`;
|
||||
- `discriminator` listens on port 8090;
|
||||
- `servewaf` listens on port 8090;
|
||||
- its state files go in `bin/state`;
|
||||
- its rule files are those of `share/rules.d`.
|
||||
- `script/example-app`: builds the image and the example app
|
||||
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
||||
`discriminator` in another, and checks them. Then it removes the containers,
|
||||
the network, the volumes and the images. Needs network access.
|
||||
`make example-app`. It checks:
|
||||
`servewaf` in another, and checks them. Then it removes the containers, the
|
||||
network, the volumes and the images. Needs network access. `make example-app`.
|
||||
It checks:
|
||||
- the health checks;
|
||||
- a request passed to the app;
|
||||
- a ban for a broken limit, and a ban for a probe made permanent;
|
||||
- that only `discriminator`'s container changes the state directory's owner;
|
||||
- with `discriminator` stopped, the app still answering through `rproxy`;
|
||||
- with `discriminator` started again, bans applying again;
|
||||
- that only `servewaf`'s container changes the state directory's owner;
|
||||
- with `servewaf` stopped, the app still answering through `rproxy`;
|
||||
- with `servewaf` started again, bans applying again;
|
||||
- orderly stops with `sv stop` and `docker stop`;
|
||||
- bans surviving a new container on the same volume;
|
||||
- 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
|
||||
|
||||
@@ -749,7 +798,7 @@ on the host.
|
||||
- [`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/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
||||
`/_sww/`, with examples.
|
||||
`/_smallwebwaf/`, with examples.
|
||||
- [`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
|
||||
gets.
|
||||
@@ -757,6 +806,8 @@ on the host.
|
||||
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
||||
holds.
|
||||
- [`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
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
# 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`
|
||||
# builds.
|
||||
ARG SWW_IMAGE=smallwebwaf
|
||||
FROM ${SWW_IMAGE}
|
||||
ARG SMALLWEBWAF_IMAGE=smallwebwaf
|
||||
FROM ${SMALLWEBWAF_IMAGE}
|
||||
|
||||
# Packages the app needs, from the nixpkgs in the image.
|
||||
RUN nix-env -iA nixpkgs.busybox
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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
|
||||
# In block mode the Core Rule Set refuses everyday Gitea searches
|
||||
# and file names, and 31 refusals in a minute ban the address.
|
||||
SWWAF_WAF_MODE: detect
|
||||
volumes:
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
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:
|
||||
+26
-26
@@ -1,60 +1,60 @@
|
||||
# Admin endpoints
|
||||
|
||||
The endpoints under `/_sww/`, what each takes and what each answers.
|
||||
`sww discriminator` answers them at its own address, on the private network.
|
||||
The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
|
||||
`smallwebwaf servewaf` answers them at its own address, on the private network.
|
||||
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||
|
||||
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
||||
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
|
||||
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
|
||||
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
|
||||
[`metrics.md`](metrics.md)).
|
||||
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
||||
Needs `SWW_ADMIN_TOKEN`, as do the three below.
|
||||
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
||||
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
||||
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
||||
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
||||
- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json`
|
||||
lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below.
|
||||
- `POST /_smallwebwaf/bans`: bans a netblock. The body is a JSON object of
|
||||
`netblock`, `duration` and, if you like, `reason`. `netblock` is a netblock
|
||||
such as `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
|
||||
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
||||
wrong:
|
||||
- one that is not such an object, or has another field;
|
||||
- one longer than 4 KiB;
|
||||
- one that names an IPv4-mapped netblock, or an address with a zone.
|
||||
- `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
|
||||
`<client>`. It answers `404` when none is active.
|
||||
- `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
|
||||
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the
|
||||
address `<client>`. It answers `404` when none is active.
|
||||
- `GET /_smallwebwaf/clients/<ip>`: what `servewaf` knows of the client. Under
|
||||
`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
|
||||
address, with its notes.
|
||||
- `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers
|
||||
`204`, or `400` to a body that is no report (see "The report" in
|
||||
- `POST /_smallwebwaf/report`: takes `rproxy`'s report, without a token. It
|
||||
answers `204`, or `400` to a body that is no report (see "The report" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
|
||||
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.
|
||||
|
||||
`sww rproxy` answers only the health check of these, at its own address. It asks
|
||||
`discriminator` about any other request under `/_sww/`. One that passes the
|
||||
checks is answered `404`, so that the app never sees that prefix (see "`rproxy`"
|
||||
in [`SPEC.md`](../SPEC.md)).
|
||||
`smallwebwaf rproxy` answers only the health check of these, at its own address.
|
||||
It asks `servewaf` about any other request under `/_smallwebwaf/`. One that
|
||||
passes the checks is answered `404`, so that the app never sees that prefix (see
|
||||
"`rproxy`" in [`SPEC.md`](../SPEC.md)).
|
||||
|
||||
## Examples
|
||||
|
||||
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
|
||||
as `discriminator:8080` on its network:
|
||||
With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `servewaf` reached as
|
||||
`servewaf:8080` on its network:
|
||||
|
||||
```sh
|
||||
# Every ban.
|
||||
curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans
|
||||
curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/bans
|
||||
|
||||
# Ban 203.0.113.0/24 for seven days.
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
||||
http://discriminator:8080/_sww/bans
|
||||
http://servewaf:8080/_smallwebwaf/bans
|
||||
|
||||
# Lift the bans on 203.0.113.9.
|
||||
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
||||
http://discriminator:8080/_sww/bans/203.0.113.9
|
||||
http://servewaf:8080/_smallwebwaf/bans/203.0.113.9
|
||||
|
||||
# What discriminator knows of 203.0.113.9.
|
||||
# What servewaf knows of 203.0.113.9.
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://discriminator:8080/_sww/clients/203.0.113.9
|
||||
http://servewaf:8080/_smallwebwaf/clients/203.0.113.9
|
||||
```
|
||||
+14
-14
@@ -6,12 +6,12 @@ back or dropped.
|
||||
|
||||
## Events
|
||||
|
||||
- `ban`: a ban `discriminator` made.
|
||||
- `ban`: a ban `servewaf` made.
|
||||
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
||||
a request.
|
||||
- `core_rule_set_match`: the Core Rule Set scored a request at or over the
|
||||
threshold. The request was refused unless the alert's `detail` gives `mode`:
|
||||
`detect` or `observe`.
|
||||
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
||||
request was refused unless the alert's `detail` gives `mode`: `detect` or
|
||||
`observe`.
|
||||
- `anomaly`: a count over an anomaly threshold.
|
||||
- `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.
|
||||
@@ -34,7 +34,7 @@ on one line:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance": "fsn1/discriminator",
|
||||
"instance": "fsn1/servewaf",
|
||||
"time": "2026-10-06T12:00:50.123461Z",
|
||||
"event": "ban",
|
||||
"host": "app.example",
|
||||
@@ -77,11 +77,11 @@ on one line:
|
||||
}
|
||||
```
|
||||
|
||||
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||
one of the events above.
|
||||
- `host`: the site of the request that raised the alert, since one
|
||||
`discriminator` serves many sites. It is left out of `source_failure`,
|
||||
`file_error` and `summary`.
|
||||
- `host`: the site of the request that raised the alert, since one `servewaf`
|
||||
serves many sites. It is left out of `source_failure`, `file_error` and
|
||||
`summary`.
|
||||
- `client`: the address whose request raised the alert. Empty for
|
||||
`source_failure`, `file_error` and `summary`.
|
||||
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
||||
@@ -111,8 +111,8 @@ on one line:
|
||||
## Slack and ntfy
|
||||
|
||||
Slack and ntfy get a message with a title and a text. The title is the instance
|
||||
and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
|
||||
one line for each of these that the alert has:
|
||||
and the event, such as `fsn1/servewaf: ban`. The text is the `reason`, then one
|
||||
line for each of these that the alert has:
|
||||
|
||||
- the host, the client, the netblock and the country;
|
||||
- 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:
|
||||
|
||||
- `Title`: the title;
|
||||
- `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
|
||||
is set;
|
||||
- `Authorization`: the token, as `Bearer <token>`, while
|
||||
`SWWAF_ALERT_NTFY_TOKEN` is set;
|
||||
- `Priority` and `Tags`: by event, as listed below.
|
||||
|
||||
The priority and tag by event:
|
||||
|
||||
- `ban`: priority `default`, tag `no_entry`.
|
||||
- `permanent_ban`: priority `high`, tag `no_entry`.
|
||||
- `core_rule_set_match`: priority `default`, tag `shield`.
|
||||
- `waf_block`: priority `default`, tag `shield`.
|
||||
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
|
||||
- `reputation_hit`: priority `low`, tag `label`.
|
||||
- `source_failure` and `file_error`: priority `high`, tag `warning`.
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
# 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.
|
||||
|
||||
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`.
|
||||
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 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
|
||||
|
||||
`SWWAF_WAF_MODE` is `detect`. The Core Rule Set then logs a match, with its rule
|
||||
ids, and refuses nothing. In `block` mode, the default, it refuses everyday
|
||||
gitea searches and file names. The section "What the Core Rule Set refuses in
|
||||
`block` mode" below lists them. Each such refusal counts toward the error burst,
|
||||
so 31 in one minute ban the address.
|
||||
|
||||
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`.
|
||||
|
||||
## What the Core Rule Set refuses in `block` mode
|
||||
|
||||
These notes record what the Core Rule Set does in front of gitea in `block`
|
||||
mode, at the other defaults. They are observations, not requirements.
|
||||
`script/gitea-requests` runs gitea behind `rproxy` and `servewaf` at the
|
||||
defaults. It sends through them a sample of gitea's ordinary use and the
|
||||
requests named below. It reports each request the Core Rule Set refuses, so that
|
||||
these notes can be checked again when gitea or the Core Rule Set changes
|
||||
version. In `detect` mode, as the setup runs it, the same requests are logged
|
||||
with their rule ids, and pass.
|
||||
|
||||
Each refusal answers only that request ("Bans" in `SPEC.md` says what it counts
|
||||
toward). The request log names the rule in `waf_rule_ids`, which
|
||||
`SWWAF_WAF_DISABLED_RULES` can switch off.
|
||||
|
||||
### What it lets through
|
||||
|
||||
The Core Rule Set lets gitea's ordinary use through, apart from the refusals in
|
||||
the sections after this one:
|
||||
|
||||
- browsing and views of files in a repository, with their history, blame and the
|
||||
file tree;
|
||||
- diffs, including their hidden lines and large files, and pull request review;
|
||||
- git's clone, fetch and push over HTTP;
|
||||
- signing in, including the return to the page a visitor came from, and sign-in
|
||||
with Git Credential Manager, git-credential-oauth or tea;
|
||||
- the API's calls for a file and its commits;
|
||||
- pushing and pulling container images and packages;
|
||||
- Actions runners, and artifacts uploaded with `actions/upload-artifact@v4`;
|
||||
- posting issues, pull requests, comments, wiki pages and files saved in the web
|
||||
editor, code included, since no body is read;
|
||||
- the page gitea shows after a change, with its message naming what was done.
|
||||
|
||||
### A query string that reads as an attack
|
||||
|
||||
Most often this is a search. A search term is a query parameter, which the Core
|
||||
Rule Set checks against its lists of system files and commands (see the fourth
|
||||
change under "Attack detection" in `SPEC.md`). A release attachment uploaded
|
||||
through the API as `docker-compose.yml` is refused the same way. Examples:
|
||||
|
||||
- a name on those lists, such as `package.json`, `.gitignore` or
|
||||
`docker-compose.yml`;
|
||||
- text that starts with a command name, such as `python3` or `ssh key`, or has
|
||||
one right after a `/` in its first word, such as `feat/docker-support`;
|
||||
- a shell command with its options or a system path (`ls -la`, `sed -i`,
|
||||
`/bin/sh`), or a command in backticks;
|
||||
- script code (`fetch(`, `${VAR}`, `process.env`), HTML (`<img src=`) or an SQL
|
||||
statement (`SELECT * FROM users WHERE`);
|
||||
- a URL naming an IP address or `localhost`.
|
||||
|
||||
### A branch, tag or file name that starts with a command name
|
||||
|
||||
The Core Rule Set's list of commands (932260) holds names such as `docker-`,
|
||||
`python3`, `ansible`, `base64` and `whoami`. A query parameter holding a branch,
|
||||
tag or file name that starts with one is refused. So is one that has such a name
|
||||
right after a `/`, such as `feat/docker-support`, `fix/docker-build` or
|
||||
`renovate/docker-build-push-action-6.x`. gitea's own pages send the name in a
|
||||
query parameter that 930120, 932160 and 932260 still check:
|
||||
|
||||
- `refSubUrl`, the branch or tag of a directory listing. A listing sends it when
|
||||
it asks for its entries' last commits in a second request. It does so whenever
|
||||
gitea takes more than a second to work them out;
|
||||
- `name`, when a branch is deleted or restored on the branches page;
|
||||
- `tag`, when a release is started from a tag;
|
||||
- `template`, the file of an issue template, when an issue is opened from it;
|
||||
- `rule_name`, when a branch protection rule is opened for editing in the
|
||||
repository's settings or has just been saved.
|
||||
|
||||
What a visitor sees, for a branch named `docker-build`:
|
||||
|
||||
- A directory listing that makes that second request leaves those last commits
|
||||
out and shows an error.
|
||||
- The branches page shows an error instead of deleting or restoring the branch.
|
||||
- A release started from such a tag gets the `403` answer of `smallwebwaf` in
|
||||
place of its form. So does an issue opened from such a template, and such a
|
||||
rule opened for editing.
|
||||
- Saving such a rule stores it. gitea then returns to the branch settings page,
|
||||
which gets the `403` answer, so the save looks as if it failed.
|
||||
|
||||
### A file name in a query parameter that trips the code rules
|
||||
|
||||
The Core Rule Set's rules for Java, script, PowerShell and SQL find what they
|
||||
look for in some file names. This holds too in the ten query parameters that
|
||||
930120, 932160 and 932260 do not check (see the fourth change under "Attack
|
||||
detection" in `SPEC.md`). Examples:
|
||||
|
||||
- `runtime` in OpenJDK's `src/java.base/share/classes/java/lang/Runtime.java`
|
||||
(944110);
|
||||
- a Java class, as in `java.lang.String.md` (944130);
|
||||
- `document.write.js` (941180);
|
||||
- `process.env.d.ts` (934100);
|
||||
- `__proto__` (934130);
|
||||
- the PowerShell command `Install-Script`, as in `install-script.sh` (932120);
|
||||
- a database's name, as in PostgreSQL's `information_schema.sql` (942140).
|
||||
|
||||
For such a file, these requests are refused:
|
||||
|
||||
- the API's list of its commits (`path`);
|
||||
- the API's call for the contents of files that names it in its query (`body`);
|
||||
- the commit graph of that file alone (`file`);
|
||||
- the hidden lines of its diff (`path`), which stay hidden;
|
||||
- the sign-in page, for a visitor who opens a page of the file that needs
|
||||
signing in, such as its edit page (`redirect_to`). The visitor can still sign
|
||||
in by opening the sign-in page itself.
|
||||
|
||||
### A path
|
||||
|
||||
- A file name ending in `~`, or an `.xhtml` file whose path holds a space.
|
||||
- A path that holds what the Core Rule Set takes for a Java class (944130), as
|
||||
`java.lang.String.md` and
|
||||
`META-INF/services/org.apache.commons.logging.LogFactory` do.
|
||||
- A path that holds what it takes for Node.js code (934100), as
|
||||
`process.env.d.ts` does.
|
||||
- A path with a directory named `...` (930100, 930110), such as `.../notes.txt`.
|
||||
|
||||
For such a file, these are refused:
|
||||
|
||||
- the page of the file;
|
||||
- its raw file;
|
||||
- its history and its blame;
|
||||
- its edit page.
|
||||
|
||||
The directory that holds it lists it, and a clone carries it.
|
||||
|
||||
### A container image push that reuses a layer
|
||||
|
||||
A push can reuse a layer of an image docker has pushed to that gitea before.
|
||||
docker then asks gitea to take the layer from that image, naming it in the query
|
||||
parameter `from`, which 932260 checks. The request is refused when the image's
|
||||
owner starts with an entry on the list of commands. It is refused too when the
|
||||
image's name after the owner starts with one. Examples are `owner/python3-app`,
|
||||
`owner/docker-base`, and any image of an owner named `ansible` or `docker-team`.
|
||||
|
||||
What happens next depends on docker's image store. A docker that keeps its
|
||||
images in containerd's image store fails the push, and fails each new try the
|
||||
same way. A new install of Docker Engine 29 does this. A docker that keeps them
|
||||
in its own older image store uploads the layer instead. The push then succeeds
|
||||
after one refusal for each layer it reuses, and each of those refusals counts
|
||||
toward the error burst ("Attack detection" in `SPEC.md`).
|
||||
|
||||
### Artifact uploads from `actions/upload-artifact@v3`
|
||||
|
||||
This action sends the header `Content-Range`, which the Core Rule Set refuses
|
||||
(920450). The action sends two files at a time, does not retry a `403`, and
|
||||
sends no further file once one is refused. So an upload is refused once or
|
||||
twice, however many files it holds, and the step fails. One upload does not ban
|
||||
the runner. The error burst ("Attack detection" in `SPEC.md`) bans it after more
|
||||
than 30 such refusals from its address in a minute, as when many jobs upload at
|
||||
once. `actions/upload-artifact@v4` does not send the header.
|
||||
|
||||
### What body inspection refuses
|
||||
|
||||
With `SWWAF_WAF_BODY_LIMIT` set, the Core Rule Set reads request bodies too. In
|
||||
front of gitea it then refuses these, until their rule ids, named in the request
|
||||
log, are added to `SWWAF_WAF_DISABLED_RULES`:
|
||||
|
||||
- issue and comment text, wiki pages and files saved in the web editor that hold
|
||||
shell commands or code (932125, 932235, 932250 and others);
|
||||
- package descriptions that show code;
|
||||
- PyPI uploads (922130);
|
||||
- attachments named like `debug.log` or `config.yml` (932180).
|
||||
|
||||
### What `script/gitea-requests` sends
|
||||
|
||||
Of the requests above, the script sends:
|
||||
|
||||
- for each file named under "A file name in a query parameter that trips the
|
||||
code rules", the API's list of its commits;
|
||||
- for `Runtime.java` alone, the commit graph of that file, the hidden lines of
|
||||
its diff, and the API's call for the contents of files that names it;
|
||||
- the sign-in page for a visitor who opens the edit page of `document.write.js`;
|
||||
- the pages of `java.lang.String.md`,
|
||||
`META-INF/services/org.apache.commons.logging.LogFactory`, `process.env.d.ts`
|
||||
and `.../notes.txt`;
|
||||
- one container image push that reuses a layer, which it does not try again.
|
||||
|
||||
It does not send the raw file, history, blame or edit page of those four files,
|
||||
or the pages of the directories `META-INF/services` and `...`. It also sends a
|
||||
short sample of gitea's ordinary use:
|
||||
|
||||
- signing in;
|
||||
- pages of files and commits, and a diff;
|
||||
- the API;
|
||||
- a push, and clone and fetch over HTTP;
|
||||
- two container image pushes.
|
||||
+9
-8
@@ -1,7 +1,7 @@
|
||||
# What the image holds
|
||||
|
||||
The contents of the `smallwebwaf` image, and how each part is pinned. The image
|
||||
runs `rproxy` by default, or `discriminator` with `SWW_COMMAND=discriminator`.
|
||||
runs `rproxy` by default, or `servewaf` with `SMALLWEBWAF_COMMAND=servewaf`.
|
||||
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
||||
is pinned that way.
|
||||
|
||||
@@ -15,12 +15,13 @@ is pinned that way.
|
||||
Unpacked, nixpkgs takes about 500 MiB.
|
||||
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
||||
at a fixed commit hash.
|
||||
- The `sww` binary, and the user `sww` (uid and gid 65532).
|
||||
- The service directory `/etc/service/sww`, with the `run` script
|
||||
`share/sww.run`.
|
||||
- `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
|
||||
the default rule file `share/rules.d/00-default.rules`.
|
||||
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
|
||||
- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532).
|
||||
- The service directory `/etc/service/smallwebwaf`, with the `run` script
|
||||
`share/smallwebwaf.run`.
|
||||
- `/var/lib/smallwebwaf` for `servewaf`'s state files, and
|
||||
`/etc/smallwebwaf/rules.d` with the default rule file
|
||||
`share/rules.d/00-default.rules`.
|
||||
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`.
|
||||
|
||||
## Pins
|
||||
|
||||
@@ -32,4 +33,4 @@ is pinned that way.
|
||||
- `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
|
||||
build writes.
|
||||
- The Go image that builds `sww`: by digest.
|
||||
- The Go image that builds `smallwebwaf`: by digest.
|
||||
+48
-43
@@ -1,85 +1,90 @@
|
||||
# The metrics
|
||||
|
||||
What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
|
||||
What `GET /_smallwebwaf/metrics` answers with, at `servewaf`'s own address.
|
||||
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
||||
keeps the `instance` label as `exported_instance` unless the scrape sets
|
||||
`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
|
||||
|
||||
- `sww_requests_total`, `sww_request_bytes_total` and
|
||||
`sww_response_bytes_total`, by `status_class` such as `2xx` and by `action`;
|
||||
- `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
|
||||
histograms;
|
||||
- `sww_requests_in_flight`: the questions and requests to its own endpoints that
|
||||
`discriminator` is handling.
|
||||
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
||||
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by
|
||||
`action`;
|
||||
- `smallwebwaf_request_duration_seconds` and
|
||||
`smallwebwaf_upstream_duration_seconds`, as histograms;
|
||||
- `smallwebwaf_requests_in_flight`: the questions and requests to its own
|
||||
endpoints that `servewaf` is handling.
|
||||
|
||||
## Limits and bans
|
||||
|
||||
- `sww_rate_limit_hits_total`, by `window` and by `kind` (`requests`, `bytes` or
|
||||
`refusals`);
|
||||
- `sww_offences_total`, by `kind`;
|
||||
- `sww_bans_made_total`, by `cause`;
|
||||
- `sww_active_bans` and `sww_permanent_bans`.
|
||||
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`,
|
||||
`bytes` or `refusals`);
|
||||
- `smallwebwaf_offences_total`, by `kind`;
|
||||
- `smallwebwaf_bans_made_total`, by `cause`;
|
||||
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
|
||||
|
||||
## Attack detection
|
||||
|
||||
- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
|
||||
- `sww_core_rule_set_matches_total`, by `mode` and `rule_id`, for the rules that
|
||||
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and
|
||||
`smallwebwaf_rules_loaded`;
|
||||
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that
|
||||
fired.
|
||||
|
||||
## Countries and AS numbers
|
||||
|
||||
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||
|
||||
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
||||
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
||||
`country`;
|
||||
- `sww_asn_requests_total`, `sww_asn_request_bytes_total` and
|
||||
`sww_asn_response_bytes_total`, by `asn`.
|
||||
- `smallwebwaf_country_requests_total`,
|
||||
`smallwebwaf_country_request_bytes_total`,
|
||||
`smallwebwaf_country_response_bytes_total` and
|
||||
`smallwebwaf_country_list_refusals_total`, by `country`;
|
||||
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and
|
||||
`smallwebwaf_asn_response_bytes_total`, by `asn`.
|
||||
|
||||
## Lookups
|
||||
|
||||
- `sww_geojs_requests_total` and `sww_geojs_failures_total`;
|
||||
- `sww_requests_without_geojs_answer_total`: the requests that needed an answer
|
||||
and went on without it;
|
||||
- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
|
||||
`sww_lookup_database_read_failures_total`.
|
||||
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`;
|
||||
- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and
|
||||
went on without it;
|
||||
- with the file source,
|
||||
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and
|
||||
`smallwebwaf_lookup_database_read_failures_total`.
|
||||
|
||||
## Reputation sources
|
||||
|
||||
By `source`: each list's URL, each zone, and `abuseipdb`:
|
||||
|
||||
- `sww_reputation_hits_total`, `sww_reputation_queries_total` and
|
||||
`sww_reputation_failures_total`;
|
||||
- `sww_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||
- `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||
- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total`
|
||||
and `smallwebwaf_reputation_failures_total`;
|
||||
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||
|
||||
## State files
|
||||
|
||||
By `file`:
|
||||
|
||||
- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
|
||||
`sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
|
||||
- `sww_state_file_edits_taken_in_total` and
|
||||
`sww_state_file_edits_set_aside_total`;
|
||||
- `sww_tracked_clients`: the clients in the table.
|
||||
- `smallwebwaf_state_file_writes_total`,
|
||||
`smallwebwaf_state_file_write_failures_total`,
|
||||
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
||||
`smallwebwaf_state_file_size_bytes`;
|
||||
- `smallwebwaf_state_file_edits_taken_in_total` and
|
||||
`smallwebwaf_state_file_edits_set_aside_total`;
|
||||
- `smallwebwaf_tracked_clients`: the clients in the table.
|
||||
|
||||
## Remote log, alerts and the process
|
||||
|
||||
- while a syslog server is set, `sww_remote_log_lines_sent_total`,
|
||||
`sww_remote_log_lines_dropped_total` and `sww_remote_log_lines_waiting`;
|
||||
- by `destination`, `sww_alerts_sent_total`, `sww_alert_send_failures_total`,
|
||||
`sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
|
||||
- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`,
|
||||
`smallwebwaf_remote_log_lines_dropped_total` and
|
||||
`smallwebwaf_remote_log_buffer_depth`;
|
||||
- by `destination`, `smallwebwaf_alerts_sent_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.
|
||||
|
||||
## When a request is counted
|
||||
|
||||
`discriminator` counts a denied request at its decision, and a request to its
|
||||
own endpoints when it is answered. It counts an allowed request when its report
|
||||
`servewaf` counts a denied request at its decision, and a request to its 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:
|
||||
|
||||
- a report itself;
|
||||
|
||||
+72
-91
@@ -1,32 +1,32 @@
|
||||
# The request log's fields
|
||||
|
||||
The fields of the JSON objects `rproxy` and `discriminator` write about each
|
||||
request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
|
||||
is written and how the log is sent to a syslog server. A field that does not
|
||||
apply to a request is left out.
|
||||
The fields of the JSON objects `rproxy` and `servewaf` write about each request.
|
||||
"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is
|
||||
written and how the log is sent to a syslog server. A field that does not apply
|
||||
to a request is left out.
|
||||
|
||||
## `discriminator`'s lines
|
||||
## `servewaf`'s lines
|
||||
|
||||
`sww discriminator` writes two lines for each question it answers, joined by
|
||||
`request_id`. The field `discriminator_line` says which of the two a line is:
|
||||
`smallwebwaf servewaf` writes two lines for each question it answers, joined by
|
||||
`request_id`. The field `servewaf_line` says which of the two a line is:
|
||||
|
||||
- `decision`: the line it writes once it has answered the question;
|
||||
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
||||
known. The repetition is intended.
|
||||
|
||||
A request to its own endpoints writes one line, without `discriminator_line`. A
|
||||
A request to its own endpoints writes one line, without `servewaf_line`. A
|
||||
report writes no line but the `report` line of the request it names.
|
||||
|
||||
These questions write no line:
|
||||
|
||||
- a question refused with `400` (see "The question" under "`discriminator`" in
|
||||
- a question refused with `400` (see "The question" under "`servewaf`" in
|
||||
[`SPEC.md`](../SPEC.md));
|
||||
- a question whose body stopped arriving, which gets no answer.
|
||||
|
||||
A `report` line:
|
||||
|
||||
```
|
||||
{"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}
|
||||
{"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}
|
||||
```
|
||||
|
||||
### Every line
|
||||
@@ -37,14 +37,14 @@ Every one of these lines has:
|
||||
- the standard web log fields;
|
||||
- `request_id`, `peer_ip` and `client_group`;
|
||||
- `asn`, `as_name` and `country`;
|
||||
- `action` and `duration_total_ms`.
|
||||
- `action` and `duration_total`.
|
||||
|
||||
### Standard web log fields
|
||||
|
||||
- `time`: when `discriminator` had read the question's body, RFC 3339 with
|
||||
- `time`: when `servewaf` had read the question's body, RFC 3339 with
|
||||
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
||||
line.
|
||||
- `instance`: `SWW_INSTANCE_NAME`.
|
||||
- `instance`: `SWWAF_INSTANCE_NAME`.
|
||||
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
||||
- `method`, `path` and `query`: the visitor's request.
|
||||
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
||||
@@ -52,9 +52,8 @@ Every one of these lines has:
|
||||
- `host`: the site the visitor asked for.
|
||||
- `protocol`: the question's own, `HTTP/1.1`.
|
||||
- `status`:
|
||||
- on the `decision` line, what `discriminator` answered: `200` for `allow`,
|
||||
the status of `deny`, or `0` for `close`, as the visitor is then sent
|
||||
nothing;
|
||||
- on the `decision` line, what `servewaf` answered: `200` for `allow`, 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.
|
||||
- `request_bytes`:
|
||||
- on the `decision` line, how much of the question's body the Core Rule Set
|
||||
@@ -62,7 +61,7 @@ Every one of these lines has:
|
||||
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
|
||||
with what a WebSocket carried from the visitor.
|
||||
- `response_bytes`:
|
||||
- on the `decision` line, the body of `discriminator`'s answer;
|
||||
- on the `decision` line, the body of `servewaf`'s answer;
|
||||
- on the `report` line, the bytes of the answer's body, with what a
|
||||
WebSocket carried from the app.
|
||||
- `referer` and `user_agent`.
|
||||
@@ -79,109 +78,91 @@ Every one of these lines has:
|
||||
- lookups are off;
|
||||
- the client is not looked up;
|
||||
- the source cannot place the client;
|
||||
- GeoJS had not answered by the time `discriminator` answered.
|
||||
- GeoJS had not answered by the time `servewaf` answered.
|
||||
- `content_type`: the visitor's `Content-Type`.
|
||||
- `content_length`: the body length the visitor announced.
|
||||
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
|
||||
lower case.
|
||||
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
|
||||
in lower case.
|
||||
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
||||
Their values are never logged.
|
||||
|
||||
### Response detail
|
||||
|
||||
- `response_content_type`: the type of `discriminator`'s own answer.
|
||||
- `response_content_type`: the type of `servewaf`'s own answer.
|
||||
- `aborted`: true when `rproxy` went away before the answer.
|
||||
|
||||
### Decision
|
||||
|
||||
- `action`: what was done:
|
||||
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||
- `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with
|
||||
the action `deny`, or for a tag in `SWW_TAG_DENY`;
|
||||
- `temporarily_banned`: refused under a ban that runs out, or for a `ban`
|
||||
rule, a trap path or a CrowdSec decision that made such a ban;
|
||||
- `permanently_banned`: refused under a ban that never runs out, including a
|
||||
ban this request made or made permanent;
|
||||
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
|
||||
`blocked_by_core_rule_set`: refused by that check;
|
||||
- `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/`.
|
||||
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
|
||||
the action `deny`;
|
||||
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
||||
CrowdSec decision, each of which bans the client;
|
||||
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||
refused by that check;
|
||||
- `too_large`: over a header limit;
|
||||
- `admin`: answered at one of `servewaf`'s own endpoints, or `404` for a
|
||||
path under `/_smallwebwaf/`.
|
||||
- `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
|
||||
`forward`.
|
||||
- `heuristic`: the heuristic that refused the request, in `observe` mode too. It
|
||||
is left out when none did.
|
||||
- `heuristic`: the heuristic that refused the request or banned its client, in
|
||||
`observe` mode too. It is left out when none did.
|
||||
- `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;
|
||||
- `deny_nets`: `netblock`, the entry of `SWWAF_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`;
|
||||
- `abuseipdb`: `score`, and `min_score`, `SWWAF_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`;
|
||||
- `core_rule_set`: `score`, and `threshold`, `SWWAF_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.
|
||||
`refusal_limit` bans after a request refused by another heuristic has been
|
||||
answered, so it replaces that heuristic on the line. `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.
|
||||
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
|
||||
rate limits and the setting that lowered it, when one did. `bytes_percent` and
|
||||
`bytes_percent_setting` do the same for the byte limits.
|
||||
- `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
|
||||
holds:
|
||||
- `minute`, `hour` and `day`: requests;
|
||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
||||
`SWW_BYTES_COUNT` names, in that window.
|
||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
|
||||
|
||||
A count can have a fraction (see "Counting method" in
|
||||
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
||||
response are added when its report comes. So on the `decision` line, the
|
||||
three byte totals leave them out.
|
||||
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
|
||||
its report comes. So on the `decision` line, the bytes are without this
|
||||
request's.
|
||||
|
||||
- `limit_hit`: the limit broken:
|
||||
- `minute`, `hour` or `day` for a rate limit;
|
||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||
- `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`).
|
||||
- `error_burst`.
|
||||
|
||||
When the bytes counted for a request and its response break a limit, the
|
||||
request has been passed on whole, so its `action` stays `forward`. Only its
|
||||
`report` line gives that limit.
|
||||
A request whose bytes broke a limit was 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
|
||||
refused.
|
||||
- `core_rule_set_score` and `core_rule_set_rule_ids`: the Core Rule Set's score
|
||||
and the rules that matched, for a request it inspected.
|
||||
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that
|
||||
matched, for a request it inspected.
|
||||
- `reputation`: the sources the client is on, in this order:
|
||||
- blocklist URLs;
|
||||
- the CrowdSec decision list's URL;
|
||||
- DNS blocklist zones;
|
||||
- `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.
|
||||
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||
refused under one: a time, or `permanent`.
|
||||
@@ -190,33 +171,33 @@ Every one of these lines has:
|
||||
|
||||
All in milliseconds:
|
||||
|
||||
- `duration_total_ms`:
|
||||
- on the `decision` line, from `discriminator` having read the question's
|
||||
body to the line;
|
||||
- `duration_total`:
|
||||
- on the `decision` line, from `servewaf` having read the question's body to
|
||||
the line;
|
||||
- on the `report` line, from `rproxy` having read the request's headers to
|
||||
the end of the answer.
|
||||
- `duration_checks_ms`: to the end of the checks;
|
||||
- `duration_core_rule_set_ms`: the part of that the Core Rule Set took;
|
||||
- `duration_upstream_total_ms`: on the `report` line of an allowed request, from
|
||||
- `duration_checks`: to the end of the checks;
|
||||
- `duration_waf`: the part of that the Core Rule Set took;
|
||||
- `duration_upstream_total`: on the `report` line of an allowed request, from
|
||||
the hand-over to the app to the end of the answer.
|
||||
|
||||
### Requests to its own endpoints
|
||||
|
||||
The line of a request to `discriminator`'s own endpoints has these fields, with
|
||||
these meanings where they differ:
|
||||
The line of a request to `servewaf`'s own endpoints has these fields, with these
|
||||
meanings where they differ:
|
||||
|
||||
- `client_ip` and `peer_ip`: the request's TCP peer.
|
||||
- `request_id`: a new random one of 26 letters and digits.
|
||||
- `scheme`: `http`.
|
||||
- `time`: when the request's headers had been read.
|
||||
- `request_bytes`: the bytes read of a ban to add, and otherwise `0`.
|
||||
- `action`: `own_endpoint`.
|
||||
- `duration_total_ms`: from the request's headers being read to the line.
|
||||
- `action`: `admin`.
|
||||
- `duration_total`: from the request's headers being read to the line.
|
||||
|
||||
## `rproxy`'s line
|
||||
|
||||
`sww rproxy` writes one short line for each request. `request_id` joins it to
|
||||
`discriminator`'s two. It has these fields and no others:
|
||||
`smallwebwaf rproxy` writes one short line for each request. `request_id` joins
|
||||
it to `servewaf`'s two. It has these fields and no others:
|
||||
|
||||
- `type`: `request`.
|
||||
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
||||
@@ -229,22 +210,22 @@ these meanings where they differ:
|
||||
a WebSocket carried from the visitor.
|
||||
- `response_bytes`: the bytes of the answer's body, with what a WebSocket
|
||||
carried from the app.
|
||||
- `duration_request_ms`: milliseconds from the request's headers being read
|
||||
until the app had been sent the whole request, or the answer started if that
|
||||
was sooner. It includes the wait for `discriminator`.
|
||||
- `duration_response_ms`: milliseconds from then until the end of the answer.
|
||||
- `duration_request`: milliseconds from the request's headers being read until
|
||||
the app had been sent the whole request, or the answer started if that was
|
||||
sooner. It includes the wait for `servewaf`.
|
||||
- `duration_response`: milliseconds from then until the end of the answer.
|
||||
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
||||
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
|
||||
left out when:
|
||||
- `discriminator` was not asked, as for the health check;
|
||||
- the visitor went away while `discriminator` was being asked;
|
||||
- `discriminator`'s answer had no valid `X-Sww-Disposition`;
|
||||
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
|
||||
is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left
|
||||
out when:
|
||||
- `servewaf` was not asked, as for the health check;
|
||||
- the visitor went away while `servewaf` was being asked;
|
||||
- `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`;
|
||||
- `rproxy` could not pass on `servewaf`'s answer, and answered `502`.
|
||||
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
||||
`disposition`, and otherwise left out.
|
||||
|
||||
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_ms":1.204,"duration_response_ms":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":1.204,"duration_response":3.104,"disposition":"allow"}
|
||||
```
|
||||
+8
-9
@@ -32,7 +32,7 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
||||
## The default file
|
||||
|
||||
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
||||
`/etc/sww/rules.d/00-default.rules`:
|
||||
`/etc/smallwebwaf/rules.d/00-default.rules`:
|
||||
|
||||
```
|
||||
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
||||
@@ -54,20 +54,19 @@ empty-agent user_agent log ^$
|
||||
|
||||
## A file of your own
|
||||
|
||||
`discriminator` reads the rule files in its own container. Rules of your own go
|
||||
in a file named to sort after the default, such as `50-site.rules`:
|
||||
`servewaf` reads the rule files in its own container. Rules of your own go in a
|
||||
file named to sort after the default, such as `50-gitea.rules`:
|
||||
|
||||
```
|
||||
# 50-site.rules: WordPress probes, which this site never serves
|
||||
# 50-gitea.rules: WordPress probes, which gitea never serves
|
||||
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
||||
```
|
||||
|
||||
A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
|
||||
from, adds it:
|
||||
A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from,
|
||||
adds it:
|
||||
|
||||
```dockerfile
|
||||
COPY 50-site.rules /etc/sww/rules.d/50-site.rules
|
||||
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
|
||||
```
|
||||
|
||||
Or mount it there. A `path` rule applies on every site behind that
|
||||
`discriminator`.
|
||||
Or mount it there. A `path` rule applies on every site behind that `servewaf`.
|
||||
+188
-254
@@ -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
|
||||
that take `off`, the `_FILE` form, and what each group does and why.
|
||||
|
||||
The settings that start with `SWW_` are `discriminator`'s. Those that start with
|
||||
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
|
||||
between the two (see "The image's command" below).
|
||||
The settings that start with `SWWAF_` are `servewaf`'s. Those that start with
|
||||
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SMALLWEBWAF_COMMAND`
|
||||
chooses between the two (see "The image's command" below).
|
||||
|
||||
## Forms of values
|
||||
|
||||
@@ -28,336 +28,271 @@ between the two (see "The image's command" below).
|
||||
|
||||
## Core
|
||||
|
||||
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
|
||||
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `servewaf` listens: `:` and a
|
||||
port, such as `:9000`. Leave the host out (see "Ports" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
|
||||
in each line `discriminator` logs, and in each alert and metric, such as
|
||||
`fsn1/discriminator`. Valid UTF-8. No control character while
|
||||
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
||||
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
|
||||
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
|
||||
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
|
||||
group, from 32 to 128.
|
||||
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance`
|
||||
name in each line `servewaf` logs, and in each alert and metric, such as
|
||||
`fsn1/servewaf`. Valid UTF-8. No control character while
|
||||
`SWWAF_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
|
||||
not.
|
||||
- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`.
|
||||
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
|
||||
client's group, from 32 to 128.
|
||||
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||
number above zero.
|
||||
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
|
||||
keeps, a whole number above zero.
|
||||
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `servewaf` keeps,
|
||||
a whole number above zero.
|
||||
|
||||
## Tokens
|
||||
|
||||
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||
`/_sww/clients/<ip>`. At least 32 characters.
|
||||
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
|
||||
32 characters.
|
||||
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||
`/_smallwebwaf/clients/<ip>`. At least 32 characters.
|
||||
- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_smallwebwaf/metrics`.
|
||||
At least 32 characters.
|
||||
|
||||
## State files
|
||||
|
||||
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
|
||||
absolute path that exists and can be written.
|
||||
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
||||
files, an absolute path that exists and can be written.
|
||||
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||
written after a ban is made, lifted or made permanent. A duration.
|
||||
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||
written. A duration.
|
||||
|
||||
## Logging
|
||||
|
||||
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
|
||||
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `servewaf`'s own
|
||||
messages that are written: `debug`, `info`, `warn` or `error`.
|
||||
- `SWW_LOG_REQUEST_HEADERS` (default
|
||||
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||
request headers recorded in the log line, in either case. `Host` and
|
||||
`Transfer-Encoding` are refused; the host is the field `host`.
|
||||
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
||||
each with a host and a port and nothing more.
|
||||
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
|
||||
a `syslog+tls` server's certificate must chain to, instead of the host's own.
|
||||
A file that cannot be read, or holds no certificate, is refused.
|
||||
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
|
||||
to be sent, a whole number above zero.
|
||||
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
||||
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
||||
own. A file that cannot be read, or holds no certificate, is refused.
|
||||
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
||||
wait to be sent, a whole number above zero.
|
||||
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
|
||||
syslog record, 1 to 48 printable ASCII characters without a space.
|
||||
- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name in
|
||||
each syslog record, 1 to 48 printable ASCII characters without a space.
|
||||
|
||||
## Metrics
|
||||
|
||||
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
|
||||
get series of their own. A whole number above zero.
|
||||
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
||||
countries get series of their own. A whole number above zero.
|
||||
|
||||
## Static lists
|
||||
|
||||
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
||||
(see step 4 in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
||||
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||
exempt from rate limiting and the byte limits.
|
||||
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
||||
4 in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
|
||||
step 3 in [`SPEC.md`](../SPEC.md)).
|
||||
|
||||
## Request rate limits, per client
|
||||
|
||||
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
|
||||
- `SWWAF_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
|
||||
person opening two pages at once with an empty cache, up to 60 files each.
|
||||
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||
person opening two gitea pages at once with an empty cache, up to 60 files
|
||||
each.
|
||||
- `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
|
||||
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
||||
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
||||
person reading code in gitea for an hour, a page every 20 seconds, makes
|
||||
about 900. A scraper making one request every 2 seconds makes 1,800 an hour,
|
||||
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.
|
||||
- `SWW_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.
|
||||
- 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
|
||||
- An Actions runner asks gitea for work all day and can pass the limits. Put the
|
||||
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
||||
network where many people share one address, such as an office.
|
||||
- Counting cannot catch a client that stays under all three limits (see "Request
|
||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
|
||||
limiting and the byte limits, such as `/assets/`.
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
||||
rate limiting and the byte limits, such as `/assets/`.
|
||||
|
||||
## Byte limits, per client
|
||||
|
||||
These count body bytes, never headers. What the visitor sent over a WebSocket
|
||||
counts as request bytes, and what the app sent as response bytes. The bytes
|
||||
counted for a request and its response are added to the client's total once the
|
||||
response has ended. The request that takes a client's total past a limit is not
|
||||
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.
|
||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most
|
||||
bytes counted for a client in each window. A size or `off`.
|
||||
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||
`response`, `request` or `both`.
|
||||
|
||||
## Lookup of AS number and country
|
||||
|
||||
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||
the start while a setting that needs a lookup is set:
|
||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
||||
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an
|
||||
`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`;
|
||||
- the two country lists;
|
||||
- `SWWAF_ADD_LOOKUP_HEADERS`;
|
||||
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`;
|
||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||
- an anomaly threshold per AS number.
|
||||
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
|
||||
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
||||
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
|
||||
- `SWWAF_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
|
||||
duration; never `off`.
|
||||
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||
the client's AS number in `X-Client-ASN` and its country in
|
||||
`X-Client-Country`.
|
||||
|
||||
## Country list
|
||||
## Country lists
|
||||
|
||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||
countries whose clients get through, such as `us,de`. A country whose
|
||||
`country=` tag is in `SWW_TAG_DENY` 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.
|
||||
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
||||
such as `cn,ru,kp`.
|
||||
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||
countries whose clients get through, such as `us,de`. A country on both lists
|
||||
stops the start.
|
||||
|
||||
## Biased thresholds
|
||||
|
||||
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
||||
percentage of the byte limits alone, for the AS numbers or countries listed,
|
||||
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
||||
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
|
||||
country listed twice is refused.
|
||||
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
||||
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
||||
listed twice is refused.
|
||||
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
||||
`cn:25,ru:50`.
|
||||
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty):
|
||||
the same, for the byte limits alone.
|
||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||
lookup cannot place.
|
||||
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||
- `SWWAF_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
|
||||
`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.
|
||||
`AS64496:50`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused.
|
||||
|
||||
## Attack detection
|
||||
|
||||
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
|
||||
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
||||
rule files.
|
||||
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||
- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
||||
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||
- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||
from 1 to 4.
|
||||
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||
- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||
match. A whole number above zero, or `off`.
|
||||
- `SWW_WAF_DISABLED_RULES` (default
|
||||
- `SWWAF_WAF_DISABLED_RULES` (default
|
||||
`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
|
||||
refused: those are the rules that set the Core Rule Set up, and
|
||||
`smallwebwaf`'s own.
|
||||
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||
not inspect.
|
||||
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
|
||||
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
|
||||
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
|
||||
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||
- `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
|
||||
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
|
||||
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
|
||||
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
|
||||
file that names a number from 900000 to 900999 outside a comment, alone or in
|
||||
a range. So does a directive that removes or changes rules as the file loads,
|
||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||
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`).
|
||||
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
|
||||
the Core Rule Set reads, a size of at most `64K`. A question carries no more
|
||||
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one
|
||||
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
|
||||
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
||||
|
||||
## Ban lengths, response and scope
|
||||
|
||||
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||
- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||
duration.
|
||||
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||
within which breaking a limit again lengthens the next ban. A duration.
|
||||
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
|
||||
that would be longer is permanent. A duration.
|
||||
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||
- `SWWAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
||||
one that would be longer is permanent. A duration.
|
||||
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||
attack. A duration.
|
||||
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||
- `SWWAF_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.
|
||||
|
||||
## Reputation
|
||||
|
||||
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||
- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||
`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.
|
||||
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
|
||||
try before it is fetched again. A duration of `1h` or more.
|
||||
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
||||
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
|
||||
last try before it is fetched again. A duration of `1h` or more.
|
||||
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||
- `SWWAF_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
|
||||
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
||||
is refused, whatever its case or key.
|
||||
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
||||
- `SWWAF_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
|
||||
port, 53 when none is given. Unset, the host's resolver is used.
|
||||
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||
control character in it is refused.
|
||||
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||
from 0 to 100, that is a hit.
|
||||
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
|
||||
whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
||||
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
||||
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day,
|
||||
a 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
|
||||
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
|
||||
without the key, it is refused. So is a decision list URL that is also in
|
||||
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
|
||||
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||
`SWWAF_BLOCKLIST_URLS`, or that is `SWWAF_ASN_LIMIT_PERCENT_URL`.
|
||||
- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||
URL, it is refused.
|
||||
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||
AbuseIPDB's score is used. A duration.
|
||||
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
|
||||
with AbuseIPDB may take. A duration.
|
||||
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
||||
check with AbuseIPDB may take. A duration.
|
||||
|
||||
## Alerting
|
||||
|
||||
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
|
||||
as a JSON object. An `http` or `https` URL without a user or a fragment.
|
||||
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
|
||||
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
|
||||
value cannot hold a comma.
|
||||
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
|
||||
receives each alert as a message, checked as the webhook URL is.
|
||||
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
|
||||
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
|
||||
webhook URL is.
|
||||
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
|
||||
alert 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,
|
||||
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
|
||||
value. A value cannot hold a comma.
|
||||
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
||||
that receives each alert as a message, checked as the webhook URL is.
|
||||
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
|
||||
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
|
||||
as the webhook URL is.
|
||||
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||
character in it is refused.
|
||||
- `SWW_ALERT_EVENTS` (default
|
||||
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
|
||||
- `SWWAF_ALERT_EVENTS` (default
|
||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||
but `summary`. Any other name is refused.
|
||||
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||
repeats are held back. A duration or `off`.
|
||||
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||
whole number above zero, or `off`.
|
||||
|
||||
## Anomaly thresholds
|
||||
|
||||
Each scope has four thresholds: the most requests and the most body bytes
|
||||
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names.
|
||||
Requests are counts, bytes are sizes. Each is `off` by default. The four
|
||||
thresholds per client are:
|
||||
Each scope has four thresholds: the most requests and the most bytes counted in
|
||||
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
||||
default. The four thresholds per client are:
|
||||
|
||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||
|
||||
The other scopes' names have another prefix before the same four endings, such
|
||||
as `REQUESTS_PER_MINUTE`:
|
||||
|
||||
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||
- `SWWAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||
netblock's length:
|
||||
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||
- `SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||
client;
|
||||
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||
client.
|
||||
- `SWW_ANOMALY_ASN_...`: per AS number.
|
||||
- `SWW_ANOMALY_TOTAL_...`: the whole service.
|
||||
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
|
||||
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
|
||||
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||
- `SWWAF_ANOMALY_ASN_...`: per AS number.
|
||||
- `SWWAF_ANOMALY_TOTAL_...`: the whole service.
|
||||
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole.
|
||||
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
||||
as `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.
|
||||
|
||||
## Settings given as files
|
||||
@@ -376,9 +311,9 @@ Then mount its directory into the container read-only, and name the file:
|
||||
|
||||
```sh
|
||||
docker run \
|
||||
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
||||
-e SWW_COMMAND=discriminator \
|
||||
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
||||
-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \
|
||||
-e SMALLWEBWAF_COMMAND=servewaf \
|
||||
-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \
|
||||
...
|
||||
```
|
||||
|
||||
@@ -391,42 +326,41 @@ A few limits are fixed rather than settings:
|
||||
- at most 1,000 DNS blocklist queries are under way at once;
|
||||
- at most 20,000 anomaly counters are kept;
|
||||
- each alert destination's queue holds at most 1,000 alerts;
|
||||
- `discriminator` holds at most 10,000 answered requests awaiting their reports;
|
||||
- `discriminator` refuses a request with more than 50 cookies or more than 100
|
||||
header fields (see "Answering in time" under "`discriminator`" in
|
||||
- `servewaf` holds at most 10,000 answered requests awaiting their reports;
|
||||
- `servewaf` refuses a request with more than 50 cookies or more than 100 header
|
||||
fields (see "Answering in time" under "`servewaf`" in
|
||||
[`SPEC.md`](../SPEC.md));
|
||||
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a
|
||||
kept-open connection idle for 120 seconds;
|
||||
- `rproxy` reads at most 1 MiB of the body of `discriminator`'s answer, and
|
||||
answers `502` to a longer one.
|
||||
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers
|
||||
`502` to a longer one.
|
||||
|
||||
## `discriminator`'s answer and `rproxy`'s wait
|
||||
## `servewaf`'s answer and `rproxy`'s wait
|
||||
|
||||
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
|
||||
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
|
||||
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
|
||||
in the background (see "Answering in time" under "`discriminator`" in
|
||||
`SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
|
||||
Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question
|
||||
waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are
|
||||
asked in the background (see "Answering in time" under "`servewaf`" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
|
||||
## `rproxy`
|
||||
|
||||
`sww rproxy` reads the settings below and no others. They are read once at
|
||||
start, and an invalid value stops the start. None can be given as a file, since
|
||||
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
|
||||
they do.
|
||||
`smallwebwaf rproxy` reads the settings below and no others. They are read once
|
||||
at start, and an invalid value stops the start. None can be given as a file,
|
||||
since `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says
|
||||
what they do.
|
||||
|
||||
A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
|
||||
- `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or
|
||||
- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or
|
||||
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||
speaks HTTP/1.1 to it.
|
||||
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
|
||||
`discriminator`'s answer, to a question and to a report. A time; never `off`.
|
||||
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'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
|
||||
`discriminator` cannot be connected to, does not answer at all, or does not
|
||||
answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
|
||||
answers `503`. It does not apply to an answer `discriminator` gives, such as
|
||||
an error.
|
||||
`servewaf` cannot be connected to, does not answer at all, or does not answer
|
||||
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
|
||||
`503`. It does not apply to an answer `servewaf` gives, such as an error.
|
||||
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
||||
`off`.
|
||||
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
||||
@@ -450,22 +384,22 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
|
||||
## The image's command
|
||||
|
||||
`SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health
|
||||
check checks: `rproxy` or `discriminator` (see "Deployment" in
|
||||
`SMALLWEBWAF_COMMAND` (default `rproxy`) chooses what the image runs and what
|
||||
its health check checks: `rproxy` or `servewaf` (see "Deployment" in
|
||||
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
|
||||
|
||||
## Where earlier settings went
|
||||
|
||||
Nothing reads these names any more:
|
||||
|
||||
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
||||
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
||||
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
||||
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
||||
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
|
||||
- `SWWAF_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
||||
- `SWWAF_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
||||
- `SWWAF_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
||||
- `SWWAF_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
||||
- `SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` are
|
||||
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
||||
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
|
||||
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` and `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` are
|
||||
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
||||
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
|
||||
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
||||
- `SWWAF_CLIENT_IDLE_TIMEOUT` and `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` have
|
||||
no setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
||||
takes a request line and headers of at most 32 KiB.
|
||||
+10
-11
@@ -1,13 +1,13 @@
|
||||
# State files
|
||||
|
||||
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
|
||||
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
|
||||
a top-level `version` of 1.
|
||||
The five JSON files in `SWWAF_STATE_DIR`, and what each holds. "Persistent
|
||||
state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited.
|
||||
Each has a top-level `version` of 1.
|
||||
|
||||
## `bans.json`
|
||||
|
||||
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
||||
`discriminator` made. Per entry:
|
||||
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of
|
||||
those `servewaf` made. Per entry:
|
||||
|
||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||
@@ -61,11 +61,10 @@ since it was first seen:
|
||||
- the first and last time seen;
|
||||
- the AS number, AS name and country last looked up, and when;
|
||||
- total requests, and how many were forwarded and how many refused;
|
||||
- body bytes of its requests, and of the responses it was sent;
|
||||
- bytes in each direction;
|
||||
- responses by status class;
|
||||
- offences by kind: `limit`, `attack`, `blocked_by_block_rule` and
|
||||
`blocked_by_core_rule_set`. A file may also hold `token_refused`, which is
|
||||
kept, and which nothing adds to.
|
||||
- offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file
|
||||
may also hold `token_refused`, which is kept, and which nothing adds to.
|
||||
|
||||
## `lookups.json`
|
||||
|
||||
@@ -94,10 +93,10 @@ GeoJS answers, one per line:
|
||||
## Sizes
|
||||
|
||||
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
||||
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||
`SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||
disk writes a day.
|
||||
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
||||
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||
the default `SWWAF_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
|
||||
more.
|
||||
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
||||
|
||||
+46
-48
@@ -1,12 +1,12 @@
|
||||
// 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
|
||||
// destination set: to the webhook
|
||||
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||
// object" in docs/alerts.md describes, to the Slack incoming
|
||||
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
|
||||
// SWW_ALERT_COOLDOWN is held back, and so is an alert past
|
||||
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within
|
||||
// SWWAF_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
|
||||
// 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
|
||||
// state is written to alerts.json and read from it by the state package.
|
||||
@@ -34,45 +34,45 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// The events an alert is for, as SWW_ALERT_EVENTS names them.
|
||||
// The events an alert is for, as SWWAF_ALERT_EVENTS names them.
|
||||
const (
|
||||
// EventBan is a ban sww made.
|
||||
// EventBan is a ban smallwebwaf made.
|
||||
EventBan = "ban"
|
||||
// EventPermanentBan is a permanent ban sww made, or a ban it
|
||||
// EventPermanentBan is a permanent ban smallwebwaf made, or a ban it
|
||||
// made permanent.
|
||||
EventPermanentBan = "permanent_ban"
|
||||
// EventAnomaly is a count of requests or bytes over an anomaly
|
||||
// threshold.
|
||||
EventAnomaly = "anomaly"
|
||||
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
|
||||
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||
// EventWAFBlock is a request the Core Rule Set scored at or over
|
||||
// SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||
// detect mode.
|
||||
EventCoreRuleSetMatch = "core_rule_set_match"
|
||||
EventWAFBlock = "waf_block"
|
||||
// EventReputationHit is a request whose client a blocklist, the
|
||||
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
||||
// is a hit.
|
||||
EventReputationHit = "reputation_hit"
|
||||
// EventSourceFailure is GeoJS failing or refusing sww, a fetch
|
||||
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
|
||||
// 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.
|
||||
EventSourceFailure = "source_failure"
|
||||
// EventFileError is a rule file or state file edited while sww
|
||||
// EventFileError is a rule file or state file edited while smallwebwaf
|
||||
// runs that does not parse, a replacement of the lookup database that
|
||||
// cannot be read, or a state file that cannot be written.
|
||||
EventFileError = "file_error"
|
||||
// EventSummary is the summary sent as an hour ends: of the alerts held
|
||||
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||
// back in it past SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||
// back by the cooldowns dropped as it ends, which no alert let through
|
||||
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
|
||||
// those repeats. SWW_ALERT_EVENTS does not name it.
|
||||
// has given. It is sent with SWWAF_ALERT_MAX_PER_HOUR off too, for
|
||||
// those repeats. SWWAF_ALERT_EVENTS does not name it.
|
||||
EventSummary = "summary"
|
||||
)
|
||||
|
||||
// Events returns every event SWW_ALERT_EVENTS can name, which is its
|
||||
// Events returns every event SWWAF_ALERT_EVENTS can name, which is its
|
||||
// default.
|
||||
func Events() []string {
|
||||
return []string{
|
||||
EventBan, EventPermanentBan, EventCoreRuleSetMatch, EventAnomaly,
|
||||
EventBan, EventPermanentBan, EventWAFBlock, EventAnomaly,
|
||||
EventReputationHit, EventSourceFailure, EventFileError,
|
||||
}
|
||||
}
|
||||
@@ -80,12 +80,12 @@ func Events() []string {
|
||||
// The destinations alerts are sent to, as the metrics and alerts.json
|
||||
// name them.
|
||||
const (
|
||||
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
|
||||
// DestinationWebhook is the webhook SWWAF_ALERT_WEBHOOK_URL names.
|
||||
DestinationWebhook = "webhook"
|
||||
// DestinationSlack is the Slack incoming webhook
|
||||
// SWW_ALERT_SLACK_WEBHOOK_URL names.
|
||||
// SWWAF_ALERT_SLACK_WEBHOOK_URL names.
|
||||
DestinationSlack = "slack"
|
||||
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
|
||||
// DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names.
|
||||
DestinationNtfy = "ntfy"
|
||||
)
|
||||
|
||||
@@ -121,26 +121,26 @@ var (
|
||||
// NtfyURL set, no alert is sent.
|
||||
type Params struct {
|
||||
// WebhookURL is where each alert is posted as JSON
|
||||
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
||||
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS).
|
||||
WebhookURL *url.URL
|
||||
WebhookHeaders http.Header
|
||||
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
||||
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||
// message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||
SlackURL *url.URL
|
||||
// NtfyURL is the ntfy topic each alert is published to
|
||||
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
|
||||
// (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||
// empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN).
|
||||
NtfyURL *url.URL
|
||||
NtfyToken string
|
||||
// Events are the events alerts are sent for (SWW_ALERT_EVENTS).
|
||||
// Events are the events alerts are sent for (SWWAF_ALERT_EVENTS).
|
||||
Events []string
|
||||
// Cooldown is how long a repeat of an alert is held back
|
||||
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||
// (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||
// sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||
Cooldown time.Duration
|
||||
MaxPerHour int
|
||||
// Instance is SWW_INSTANCE_NAME, which every alert gives.
|
||||
// Instance is SWWAF_INSTANCE_NAME, which every alert gives.
|
||||
Instance string
|
||||
// Now tells the time of an alert, normally time.Now in UTC.
|
||||
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,
|
||||
// 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
|
||||
// the site of the request that raised the alert, while one discriminator serves
|
||||
// the site of the request that raised the alert, while one servewaf serves
|
||||
// many sites, and "" otherwise.
|
||||
//
|
||||
//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
|
||||
// queue or given up as it refused them.
|
||||
type Counts struct {
|
||||
Sent, SendFailures, Dropped int64
|
||||
Sent, Failed, Dropped int64
|
||||
}
|
||||
|
||||
// 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 []*Alert
|
||||
|
||||
sent, sendFailures, dropped atomic.Int64
|
||||
sent, failed, dropped atomic.Int64
|
||||
}
|
||||
|
||||
// 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 {
|
||||
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
|
||||
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL",
|
||||
params.WebhookURL, q.webhookMessage)
|
||||
}
|
||||
|
||||
if params.SlackURL != nil {
|
||||
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
|
||||
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL",
|
||||
params.SlackURL, slackMessage)
|
||||
}
|
||||
|
||||
if params.NtfyURL != nil {
|
||||
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
|
||||
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL",
|
||||
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,
|
||||
// unless no destination is set or SWW_ALERT_EVENTS leaves its event
|
||||
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event
|
||||
// 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
|
||||
// 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
|
||||
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
|
||||
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses
|
||||
// event, no alert for event on netblock was let through less than
|
||||
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
||||
// in the hour under way. Unlike Raise, it counts nothing.
|
||||
@@ -426,9 +426,7 @@ func (q *Queue) Counts(name string) Counts {
|
||||
for _, d := range q.destinations {
|
||||
if d.name == name {
|
||||
return Counts{
|
||||
Sent: d.sent.Load(),
|
||||
SendFailures: d.sendFailures.Load(),
|
||||
Dropped: d.dropped.Load(),
|
||||
Sent: d.sent.Load(), Failed: d.failed.Load(), Dropped: d.dropped.Load(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -608,13 +606,13 @@ func (q *Queue) endHour(now time.Time) {
|
||||
|
||||
if heldBack > 0 {
|
||||
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
||||
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||
"past the %d an hour SWWAF_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
||||
}
|
||||
|
||||
if repeats > 0 {
|
||||
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
||||
"SWW_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||
"SWWAF_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||
}
|
||||
|
||||
if len(reasons) > 0 {
|
||||
@@ -677,7 +675,7 @@ func (d *destination) run(ctx context.Context) {
|
||||
retryAt = time.Time{}
|
||||
case errors.Is(err, errRefused):
|
||||
d.remove(alert)
|
||||
d.sendFailures.Add(1)
|
||||
d.failed.Add(1)
|
||||
d.dropped.Add(1)
|
||||
|
||||
retryDelay = 0
|
||||
@@ -685,8 +683,8 @@ func (d *destination) run(ctx context.Context) {
|
||||
|
||||
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
||||
"event", alert.Event, "error", err.Error())
|
||||
case ctx.Err() == nil: // not cut off as sww stops
|
||||
d.sendFailures.Add(1)
|
||||
case ctx.Err() == nil: // not cut off as smallwebwaf stops
|
||||
d.failed.Add(1)
|
||||
|
||||
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
||||
maxRetryDelay)
|
||||
@@ -873,7 +871,7 @@ func ntfyPriority(event string) string {
|
||||
return "high"
|
||||
case EventReputationHit:
|
||||
return "low"
|
||||
default: // ban, core_rule_set_match and summary
|
||||
default: // ban, waf_block and summary
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
@@ -884,7 +882,7 @@ func ntfyTag(event string) string {
|
||||
switch event {
|
||||
case EventBan, EventPermanentBan:
|
||||
return "no_entry"
|
||||
case EventCoreRuleSetMatch:
|
||||
case EventWAFBlock:
|
||||
return "shield"
|
||||
case EventAnomaly:
|
||||
return "chart_with_upwards_trend"
|
||||
|
||||
@@ -34,12 +34,12 @@ const (
|
||||
// webhookURL is where the alerts are posted, slackURL the Slack
|
||||
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
||||
// token of the tests that set one.
|
||||
webhookURL = "https://alerts.example/sww?team=ops"
|
||||
webhookURL = "https://alerts.example/smallwebwaf?team=ops"
|
||||
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||
ntfyURL = "https://ntfy.example/sww-alerts"
|
||||
ntfyURL = "https://ntfy.example/smallwebwaf-alerts"
|
||||
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
||||
// instance is the instance name every alert gives.
|
||||
instance = "fsn1app1/blog"
|
||||
instance = "fsn1app1/gitea"
|
||||
// started is when each test starts, as an alert gives it, and
|
||||
// anHourOn an hour later.
|
||||
started = "2000-01-01T00:00:00Z"
|
||||
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
|
||||
|
||||
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
||||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
||||
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
|
||||
t.Error("a ban alert would be sent, though SWWAF_ALERT_EVENTS leaves it out")
|
||||
}
|
||||
|
||||
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
||||
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
|
||||
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
||||
|
||||
for setting, params := range map[string]alerts.Params{
|
||||
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||
"SWW_ALERT_NTFY_URL": onlyNtfy,
|
||||
"SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||
"SWWAF_ALERT_NTFY_URL": onlyNtfy,
|
||||
} {
|
||||
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
||||
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": "",
|
||||
"country": "",
|
||||
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
|
||||
"past the 2 an hour SWWAF_ALERT_MAX_PER_HOUR allows",
|
||||
"detail": map[string]any{
|
||||
"hour": started,
|
||||
"count": float64(3),
|
||||
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
|
||||
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
||||
|
||||
if !strings.Contains(log.String(),
|
||||
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
|
||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) {
|
||||
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)
|
||||
|
||||
if !strings.Contains(log.String(),
|
||||
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
|
||||
`"msg":"gave up an alert SWWAF_ALERT_WEBHOOK_URL refused"`) {
|
||||
t.Errorf("process log %q names no alert given up", log.String())
|
||||
}
|
||||
})
|
||||
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
|
||||
|
||||
logged := log.String()
|
||||
if !strings.Contains(logged,
|
||||
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
|
||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) ||
|
||||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
||||
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, " +
|
||||
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||
"past the 1 an hour SWWAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||
"by SWWAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||
|
||||
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
||||
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
||||
@@ -897,11 +897,11 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
|
||||
synctest.Wait()
|
||||
|
||||
wantDestinationCounts(t, q, alerts.DestinationSlack,
|
||||
alerts.Counts{Sent: 3, SendFailures: 1})
|
||||
alerts.Counts{Sent: 3, Failed: 1})
|
||||
|
||||
logged := log.String()
|
||||
if !strings.Contains(logged,
|
||||
`"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
||||
`"msg":"sending an alert to SWWAF_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
||||
strings.Contains(logged, "hooks.slack.example") ||
|
||||
strings.Contains(logged, "T0123") {
|
||||
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{
|
||||
alerts.DestinationWebhook: {Sent: all},
|
||||
alerts.DestinationSlack: {Dropped: 1},
|
||||
alerts.DestinationNtfy: {SendFailures: all, Dropped: all},
|
||||
alerts.DestinationNtfy: {Failed: all, Dropped: all},
|
||||
} {
|
||||
wantDestinationCounts(t, q, destination, want)
|
||||
}
|
||||
@@ -965,8 +965,8 @@ func TestEachDestinationIsSentOnlyTheAlertsWaitingForIt(t *testing.T) {
|
||||
synctest.Wait()
|
||||
|
||||
wantBodies(t, standIns[alerts.DestinationSlack],
|
||||
`{"text":"*fsn1app1/blog: file_error*\nsecond"}`,
|
||||
`{"text":"*fsn1app1/blog: file_error*\nthird"}`)
|
||||
`{"text":"*fsn1app1/gitea: file_error*\nsecond"}`,
|
||||
`{"text":"*fsn1app1/gitea: file_error*\nthird"}`)
|
||||
wantBodies(t, standIns[alerts.DestinationNtfy], "fourth")
|
||||
|
||||
// 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
|
||||
// back, and those it counts as dropped for the webhook.
|
||||
func wantCounts(
|
||||
t *testing.T, q *alerts.Queue, sent, sendFailures, suppressed, dropped int64,
|
||||
t *testing.T, q *alerts.Queue, sent, failed, suppressed, dropped int64,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
wantDestinationCounts(t, q, alerts.DestinationWebhook,
|
||||
alerts.Counts{Sent: sent, SendFailures: sendFailures, Dropped: dropped})
|
||||
alerts.Counts{Sent: sent, Failed: failed, Dropped: dropped})
|
||||
|
||||
if 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
|
||||
}
|
||||
|
||||
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
|
||||
// anAlertForEachEvent returns an alert for each event SWWAF_ALERT_EVENTS
|
||||
// names, the first in observe mode, each with its message.
|
||||
func anAlertForEachEvent() []eventMessage {
|
||||
return []eventMessage{
|
||||
@@ -1265,7 +1265,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Reason: "requests per hour over the limit of 10000",
|
||||
Detail: map[string]any{"mode": "observe"},
|
||||
},
|
||||
"fsn1app1/blog: ban", "default no_entry",
|
||||
"fsn1app1/gitea: ban", "default no_entry",
|
||||
"requests per hour over the limit of 10000\nclient: 203.0.113.9\n" +
|
||||
"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",
|
||||
Reason: "matched the rule env-file",
|
||||
},
|
||||
"fsn1app1/blog: permanent_ban", "high no_entry",
|
||||
"fsn1app1/gitea: permanent_ban", "high no_entry",
|
||||
"matched the rule env-file\nclient: 198.51.100.7\n" +
|
||||
"netblock: 198.51.100.7/32\ncountry: FR",
|
||||
},
|
||||
{
|
||||
alerts.Alert{
|
||||
Event: alerts.EventCoreRuleSetMatch, Client: netip.MustParseAddr("192.0.2.1"),
|
||||
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
||||
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
||||
Reason: "refused by the Core Rule Set",
|
||||
Host: "app.example",
|
||||
},
|
||||
"fsn1app1/blog: core_rule_set_match", "default shield",
|
||||
"fsn1app1/gitea: waf_block", "default shield",
|
||||
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
|
||||
"netblock: 192.0.2.1/32",
|
||||
},
|
||||
@@ -1295,7 +1295,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
|
||||
Reason: "requests per minute over the threshold of 5000",
|
||||
},
|
||||
"fsn1app1/blog: anomaly", "high chart_with_upwards_trend",
|
||||
"fsn1app1/gitea: anomaly", "high chart_with_upwards_trend",
|
||||
"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"),
|
||||
Reason: "listed by a DNS blocklist",
|
||||
},
|
||||
"fsn1app1/blog: reputation_hit", "low label",
|
||||
"fsn1app1/gitea: reputation_hit", "low label",
|
||||
"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",
|
||||
Detail: map[string]any{"source": "geojs"},
|
||||
},
|
||||
"fsn1app1/blog: source_failure", "high warning",
|
||||
"fsn1app1/gitea: source_failure", "high warning",
|
||||
"asking GeoJS failed\nsource: geojs",
|
||||
},
|
||||
{
|
||||
@@ -1320,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Event: alerts.EventFileError,
|
||||
Reason: "a rule file has an error, and the rules stay as they were",
|
||||
Detail: map[string]any{
|
||||
"file": "/etc/sww/rules.d/50-app.rules", "error": "line 2: no action",
|
||||
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action",
|
||||
},
|
||||
},
|
||||
"fsn1app1/blog: file_error", "high warning",
|
||||
"fsn1app1/gitea: file_error", "high warning",
|
||||
"a rule file has an error, and the rules stay as they were\n" +
|
||||
"file: /etc/sww/rules.d/50-app.rules\nerror: line 2: no action",
|
||||
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -28,22 +28,22 @@ const maxCounters = 20000
|
||||
// alerts name them.
|
||||
const (
|
||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
||||
// SWW_IPV6_GROUP_PREFIX.
|
||||
// SWWAF_IPV6_GROUP_PREFIX.
|
||||
ScopeClient = "client"
|
||||
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
|
||||
// or SWW_ANOMALY_NET_V6_PREFIX long.
|
||||
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX
|
||||
// or SWWAF_ANOMALY_NET_V6_PREFIX long.
|
||||
ScopeNet = "net"
|
||||
// ScopeASN is an AS number.
|
||||
ScopeASN = "asn"
|
||||
// ScopeTotal is the whole service.
|
||||
ScopeTotal = "total"
|
||||
// ScopeWatchedNet is a named netblock of SWW_WATCH_NETS.
|
||||
ScopeWatchedNet = "watched_net"
|
||||
// ScopeWatch is a named netblock of SWWAF_WATCH_NETS.
|
||||
ScopeWatch = "watch"
|
||||
)
|
||||
|
||||
// Scopes returns every scope.
|
||||
func Scopes() []string {
|
||||
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet}
|
||||
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatch}
|
||||
}
|
||||
|
||||
// The windows a counter counts in, as the alerts name them.
|
||||
@@ -62,7 +62,7 @@ type Thresholds struct {
|
||||
BytesPerHour int64
|
||||
}
|
||||
|
||||
// NamedNetblock is a netblock SWW_WATCH_NETS names.
|
||||
// NamedNetblock is a netblock SWWAF_WATCH_NETS names.
|
||||
type NamedNetblock struct {
|
||||
Name string
|
||||
Netblock netip.Prefix
|
||||
@@ -70,14 +70,14 @@ type NamedNetblock struct {
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
|
||||
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
|
||||
// SWW_WATCH_*.
|
||||
// The thresholds of each scope: SWWAF_ANOMALY_CLIENT_*,
|
||||
// SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and
|
||||
// SWWAF_WATCH_*.
|
||||
Client, Net, ASN, Total, Watch Thresholds
|
||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
|
||||
// client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX).
|
||||
NetV4Prefix, NetV6Prefix int
|
||||
// NamedNetblocks are SWW_WATCH_NETS.
|
||||
// NamedNetblocks are SWWAF_WATCH_NETS.
|
||||
NamedNetblocks []NamedNetblock
|
||||
// Alerts receive the anomaly alerts.
|
||||
Alerts *alerts.Queue
|
||||
@@ -107,13 +107,13 @@ type Request struct {
|
||||
Host string
|
||||
// Client is the client's address, and ClientGroup the client it is
|
||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||
// SWW_IPV6_GROUP_PREFIX its address is in.
|
||||
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
||||
Client netip.Addr
|
||||
ClientGroup netip.Prefix
|
||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||
// unknown.
|
||||
ASN, ASName, Country string
|
||||
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
|
||||
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them.
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ func (c *Counters) scopesOf(r Request) []scope {
|
||||
for _, named := range p.NamedNetblocks {
|
||||
if named.Netblock.Contains(client) {
|
||||
all = append(all, scope{
|
||||
key{scope: ScopeWatchedNet, netblock: named.Netblock, name: named.Name}, p.Watch,
|
||||
key{scope: ScopeWatch, 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
|
||||
case ScopeTotal:
|
||||
counted = "the whole service"
|
||||
default: // watched_net
|
||||
default: // watch
|
||||
counted = "the named netblock " + k.name + ", " + k.netblock.String()
|
||||
detail["name"] = k.name
|
||||
}
|
||||
|
||||
+18
-63
@@ -13,7 +13,7 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
|
||||
|
||||
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
|
||||
@@ -24,16 +24,16 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
|
||||
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 1
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 1
|
||||
ledger := bans.New(rules)
|
||||
adminsOnly := netip.MustParsePrefix("198.51.100.0/24")
|
||||
both := netip.MustParsePrefix("203.0.113.1/32")
|
||||
second := netip.MustParsePrefix("203.0.113.2/32")
|
||||
third := netip.MustParsePrefix("203.0.113.3/32")
|
||||
|
||||
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
||||
// then one with an admin's ban before a ban sww made: the one
|
||||
// then one with an admin's ban before a ban smallwebwaf made: the one
|
||||
// ban counted toward MaxBans.
|
||||
ledger.Load([]bans.Ban{
|
||||
{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})
|
||||
|
||||
// A new ban drops the ban sww made, and only that one.
|
||||
// A new ban drops the ban smallwebwaf made, and only that one.
|
||||
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 2
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 2
|
||||
ledger := bans.New(rules)
|
||||
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||
other := netip.MustParsePrefix("203.0.113.2/32")
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
||||
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||
|
||||
// The netblock breaks a limit twice, the second time after its first
|
||||
@@ -114,10 +114,10 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasonOfTheBansSwwMakes(t *testing.T) {
|
||||
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
|
||||
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
|
||||
@@ -150,7 +150,7 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
Lifted: midnight().Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{lifted})
|
||||
|
||||
// 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.
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{{
|
||||
Netblock: netblock,
|
||||
Start: midnight(),
|
||||
@@ -211,7 +211,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
bans.Notes{})
|
||||
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)
|
||||
}
|
||||
|
||||
// The admin keeps the ban sww made, keeps the one read at the
|
||||
// The admin keeps the ban smallwebwaf made, keeps the one read at the
|
||||
// start, and adds one without a cause: that one alone is made.
|
||||
kept := made
|
||||
kept.Cause = bans.CauseAdmin
|
||||
@@ -246,7 +246,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
|
||||
// An hour's ban for a broken limit.
|
||||
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
@@ -292,7 +292,7 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
||||
wide := netip.MustParsePrefix("203.0.113.0/24")
|
||||
other := netip.MustParsePrefix("203.0.113.10/32")
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{
|
||||
// Ended an hour ago.
|
||||
{
|
||||
@@ -341,48 +341,3 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-50
@@ -1,4 +1,4 @@
|
||||
// Package bans is the ban ledger: the bans sww makes on the
|
||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
||||
// netblocks of clients that break a rate limit, a byte limit or the error
|
||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
||||
// decision list, and
|
||||
@@ -21,14 +21,14 @@ import (
|
||||
|
||||
// The causes of bans.
|
||||
const (
|
||||
// CauseLimit is a ban sww made for a broken limit.
|
||||
// CauseLimit is a ban smallwebwaf made for a broken limit.
|
||||
CauseLimit = "limit"
|
||||
// CauseAttack is a ban sww made for a clear sign of attack.
|
||||
// CauseAttack is a ban smallwebwaf made for a clear sign of attack.
|
||||
CauseAttack = "attack"
|
||||
// CauseAdmin is a ban an admin made, or one sww made that an
|
||||
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
||||
// admin keeps. It is never dropped.
|
||||
CauseAdmin = "admin"
|
||||
// CauseCrowdSec is a ban sww made for a client the CrowdSec
|
||||
// CauseCrowdSec is a ban smallwebwaf made for a client the CrowdSec
|
||||
// decision list lists. It ends when CrowdSec's decision does.
|
||||
CauseCrowdSec = "crowdsec"
|
||||
)
|
||||
@@ -40,8 +40,8 @@ const repeatFactor = 3
|
||||
// maxTextBytes is how much of each text in a ban's notes is kept.
|
||||
const maxTextBytes = 256
|
||||
|
||||
// Params are how long a ban lasts, and how many bans are held.
|
||||
type Params struct {
|
||||
// Rules are how long a ban lasts, and how many bans are held.
|
||||
type Rules struct {
|
||||
// LimitBanDuration is how long a first ban for a broken limit lasts.
|
||||
LimitBanDuration time.Duration
|
||||
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
||||
@@ -70,7 +70,7 @@ type Ban struct {
|
||||
Expires time.Time
|
||||
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
||||
Cause string
|
||||
// Reason is a short text: for a ban sww made, the limit broken,
|
||||
// Reason is a short text: for a ban smallwebwaf made, the limit broken,
|
||||
// the rule that matched or the scenario of CrowdSec's decision; for an
|
||||
// admin's, what the admin wrote.
|
||||
Reason string
|
||||
@@ -123,7 +123,7 @@ type Notes struct {
|
||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||
// 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
|
||||
// one for a request for a path in SWW_TRAP_PATHS, that path as the
|
||||
// one for a request for a path in SWWAF_TRAP_PATHS, that path as the
|
||||
// client sent it.
|
||||
RuleID string `json:"rule_id,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
@@ -180,7 +180,7 @@ type Request struct {
|
||||
|
||||
// Ledger holds the bans. It is safe for concurrent use.
|
||||
type Ledger struct {
|
||||
params Params
|
||||
rules Rules
|
||||
// changed receives a value when a ban is made, unless one is waiting
|
||||
// already.
|
||||
changed chan struct{}
|
||||
@@ -190,7 +190,7 @@ type Ledger struct {
|
||||
// Find make each netblock they find the most recently seen.
|
||||
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
||||
// held is how many bans netblocks holds whose cause is not CauseAdmin,
|
||||
// at most params.MaxBans.
|
||||
// at most rules.MaxBans.
|
||||
held int
|
||||
// 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
|
||||
@@ -199,13 +199,13 @@ type Ledger struct {
|
||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||
// 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
|
||||
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
|
||||
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
|
||||
// or another length of an IPv6 client's netblock.
|
||||
v4Lengths, v6Lengths []int
|
||||
}
|
||||
|
||||
// New returns a Ledger with no ban yet.
|
||||
func New(params Params) *Ledger {
|
||||
func New(rules Rules) *Ledger {
|
||||
// The ledger drops bans itself, and never those whose cause is
|
||||
// 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.
|
||||
@@ -215,7 +215,7 @@ func New(params Params) *Ledger {
|
||||
}
|
||||
|
||||
return &Ledger{
|
||||
params: params,
|
||||
rules: rules,
|
||||
changed: make(chan struct{}, 1),
|
||||
netblocks: netblocks,
|
||||
made: map[string]int{},
|
||||
@@ -274,27 +274,16 @@ 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
|
||||
// is active at now, or nil when none is. If several are, it returns the
|
||||
// newest permanent one, or the newest when none is permanent. Every ban is
|
||||
// looked at, since a ban an admin adds to bans.json can start before the
|
||||
// netblock's others and outlast them.
|
||||
// one that started last. Every ban is 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 {
|
||||
var newest *Ban
|
||||
|
||||
for i := len(bans) - 1; i >= 0; i-- {
|
||||
if !bans[i].ActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
if bans[i].Permanent() {
|
||||
if bans[i].ActiveAt(now) {
|
||||
return &bans[i]
|
||||
}
|
||||
|
||||
if newest == nil {
|
||||
newest = &bans[i]
|
||||
}
|
||||
}
|
||||
|
||||
return newest
|
||||
return nil
|
||||
}
|
||||
|
||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||
@@ -592,7 +581,7 @@ func (l *Ledger) Load(bans []Ban) {
|
||||
}
|
||||
|
||||
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
||||
// sww runs. Each ban in it whose cause is CauseAdmin, and which
|
||||
// smallwebwaf 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
|
||||
// admin made, and is counted among the bans made.
|
||||
func (l *Ledger) LoadEdit(bans []Ban) {
|
||||
@@ -754,16 +743,13 @@ func (l *Ledger) markChanged() {
|
||||
}
|
||||
|
||||
// active returns the ban active at now on a netblock client is in, or
|
||||
// nil. If several netblocks have one, it returns a permanent ban when
|
||||
// there is one.
|
||||
// nil.
|
||||
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||
lengths := l.v6Lengths
|
||||
if client.Is4() {
|
||||
lengths = l.v4Lengths
|
||||
}
|
||||
|
||||
var first *Ban
|
||||
|
||||
for _, length := range lengths {
|
||||
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
||||
if !found {
|
||||
@@ -771,20 +757,12 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||
}
|
||||
|
||||
ban := activeBan(*bans, now)
|
||||
if ban == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if ban.Permanent() {
|
||||
if ban != nil {
|
||||
return ban
|
||||
}
|
||||
|
||||
if first == nil {
|
||||
first = ban
|
||||
}
|
||||
}
|
||||
|
||||
return first
|
||||
return nil
|
||||
}
|
||||
|
||||
// covering returns the bans of each netblock held that client is in,
|
||||
@@ -812,7 +790,7 @@ func (l *Ledger) covering(client netip.Addr) []*[]Ban {
|
||||
// unless ban's cause is CauseAdmin, which does not count toward MaxBans.
|
||||
func (l *Ledger) add(ban Ban) {
|
||||
counted := ban.Cause != CauseAdmin
|
||||
if counted && l.held == l.params.MaxBans {
|
||||
if counted && l.held == l.rules.MaxBans {
|
||||
l.dropOne()
|
||||
}
|
||||
|
||||
@@ -847,7 +825,7 @@ func (l *Ledger) add(ban Ban) {
|
||||
// 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.
|
||||
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||
length := l.params.LimitBanDuration
|
||||
length := l.rules.LimitBanDuration
|
||||
|
||||
var last *Ban
|
||||
|
||||
@@ -858,18 +836,18 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||
}
|
||||
}
|
||||
|
||||
if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
|
||||
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
|
||||
lastLength := last.Expires.Sub(last.Start)
|
||||
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
||||
// that it cannot overflow.
|
||||
if lastLength > l.params.MaxBanDuration/repeatFactor {
|
||||
if lastLength > l.rules.MaxBanDuration/repeatFactor {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
length = repeatFactor * lastLength
|
||||
}
|
||||
|
||||
if length > l.params.MaxBanDuration {
|
||||
if length > l.rules.MaxBanDuration {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
@@ -887,7 +865,7 @@ func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
|
||||
}
|
||||
}
|
||||
|
||||
return now.Add(l.params.AttackBanDuration)
|
||||
return now.Add(l.rules.AttackBanDuration)
|
||||
}
|
||||
|
||||
// dropOne drops the earliest ban whose cause is not CauseAdmin of the
|
||||
|
||||
+27
-27
@@ -15,7 +15,7 @@ const day = 24 * time.Hour
|
||||
func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
@@ -80,9 +80,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.LimitBanDuration = params.MaxBanDuration + time.Hour
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
|
||||
ledger := bans.New(rules)
|
||||
|
||||
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
||||
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
|
||||
// hours, is within the maximum, and three times as long would not fit
|
||||
// in a time.Duration. The 15th is permanent.
|
||||
params := defaultParams()
|
||||
params.MaxBanDuration = 100000 * day
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBanDuration = 100000 * day
|
||||
ledger := bans.New(rules)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
@@ -146,7 +146,7 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
||||
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
||||
func TestFindCountsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
@@ -200,9 +200,9 @@ func TestFindCountsNothing(t *testing.T) {
|
||||
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 3
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 3
|
||||
ledger := bans.New(rules)
|
||||
a := netip.MustParsePrefix("203.0.113.1/32")
|
||||
b := netip.MustParsePrefix("203.0.113.2/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
|
||||
// its new one, whose notes still count it.
|
||||
params := defaultParams()
|
||||
params.MaxBans = 1
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 1
|
||||
ledger := bans.New(rules)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
@@ -258,7 +258,7 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
||||
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
||||
|
||||
@@ -304,7 +304,7 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// A ban for a broken limit before does not count.
|
||||
@@ -336,7 +336,7 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
@@ -380,7 +380,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
||||
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
@@ -414,7 +414,7 @@ func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
||||
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
long := strings.Repeat("a", 300)
|
||||
request := bans.Request{
|
||||
@@ -458,7 +458,7 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
||||
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
other := netip.MustParsePrefix("198.51.100.7/32")
|
||||
|
||||
@@ -494,9 +494,9 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultParams are the params at the settings' defaults.
|
||||
func defaultParams() bans.Params {
|
||||
return bans.Params{
|
||||
// defaultRules are the rules at the settings' defaults.
|
||||
func defaultRules() bans.Rules {
|
||||
return bans.Rules{
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: day,
|
||||
MaxBanDuration: 7 * day,
|
||||
|
||||
@@ -15,7 +15,7 @@ const scenario = "crowdsecurity/ssh-bf"
|
||||
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
expires := midnight().Add(4 * time.Hour)
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
|
||||
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
expires := midnight().Add(4 * time.Hour)
|
||||
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
|
||||
@@ -72,7 +72,7 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
|
||||
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// Three times the three days would be permanent; a limit broken as the
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func TestChangedAfterABanIsMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
wantChanged(t, ledger, false)
|
||||
@@ -38,7 +38,7 @@ func TestChangedAfterABanIsMade(t *testing.T) {
|
||||
func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
v6 := netip.MustParsePrefix("2001:db8::/64")
|
||||
high := netip.MustParsePrefix("203.0.113.10/32")
|
||||
low := netip.MustParsePrefix("203.0.113.9/32")
|
||||
@@ -67,14 +67,14 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
||||
func TestLoadedBansCarryOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before := bans.New(defaultParams())
|
||||
before := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
||||
|
||||
// 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
|
||||
// times as long, with the loaded ban counted among the earlier ones.
|
||||
after := bans.New(defaultParams())
|
||||
after := bans.New(defaultRules())
|
||||
after.Load(before.Snapshot())
|
||||
|
||||
_, 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
|
||||
// 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.
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{
|
||||
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), 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),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{permanent, ended})
|
||||
|
||||
now := midnight().Add(2 * time.Hour)
|
||||
@@ -173,7 +173,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
||||
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A 9-hour ban sww made, the third in a row, and an admin's
|
||||
// A 9-hour ban smallwebwaf 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.
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
nineHours := bans.Ban{
|
||||
@@ -189,7 +189,7 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||
Expires: midnight().Add(2 * time.Hour),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{nineHours, admins})
|
||||
|
||||
// Once both have ended, a limit broken within the repeat window bans
|
||||
@@ -219,9 +219,9 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
||||
Cause: bans.CauseLimit,
|
||||
}
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 1
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 1
|
||||
ledger := bans.New(rules)
|
||||
ledger.Load([]bans.Ban{later, earlier})
|
||||
|
||||
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
|
||||
// two bans held, would drop none of them to make room.
|
||||
params := defaultParams()
|
||||
params.MaxBans = 3
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 3
|
||||
ledger := bans.New(rules)
|
||||
kept := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("2001:db8::/64"),
|
||||
Start: midnight(),
|
||||
@@ -285,7 +285,7 @@ func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{ban})
|
||||
|
||||
cut := long[:256]
|
||||
|
||||
+300
-703
File diff suppressed because it is too large.
Load diff
+177
-439
@@ -23,100 +23,98 @@ import (
|
||||
|
||||
// The settings, by name.
|
||||
const (
|
||||
listenAddr = "SWW_LISTEN_ADDR"
|
||||
mode = "SWW_MODE"
|
||||
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWW_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWW_DENY_NETS"
|
||||
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
|
||||
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWW_BYTES_COUNT"
|
||||
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
tagLists = "SWW_TAG_LISTS"
|
||||
tagDeny = "SWW_TAG_DENY"
|
||||
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||
blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
|
||||
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||
dnsblZones = "SWW_DNSBL_ZONES"
|
||||
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
|
||||
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
|
||||
reputationAction = "SWW_REPUTATION_ACTION"
|
||||
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
|
||||
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
|
||||
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
||||
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||
banResponse = "SWW_BAN_RESPONSE"
|
||||
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||
maxBans = "SWW_MAX_BANS"
|
||||
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||
stateDir = "SWW_STATE_DIR"
|
||||
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
||||
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWW_METRICS_TOP_N"
|
||||
instanceName = "SWW_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||
logLevel = "SWW_LOG_LEVEL"
|
||||
rulesDir = "SWW_RULES_DIR"
|
||||
rulesEnabled = "SWW_RULES_ENABLED"
|
||||
wafMode = "SWW_WAF_MODE"
|
||||
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
|
||||
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||
trapPaths = "SWW_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
|
||||
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
|
||||
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
|
||||
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
|
||||
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
|
||||
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
|
||||
alertNtfyURL = "SWW_ALERT_NTFY_URL"
|
||||
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||
alertEvents = "SWW_ALERT_EVENTS"
|
||||
alertCooldown = "SWW_ALERT_COOLDOWN"
|
||||
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWW_WATCH_NETS"
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
mode = "SWWAF_MODE"
|
||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWWAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWWAF_BYTES_COUNT"
|
||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
||||
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH"
|
||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
||||
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
||||
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
||||
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
||||
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
stateDir = "SWWAF_STATE_DIR"
|
||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
logLevel = "SWWAF_LOG_LEVEL"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||
wafMode = "SWWAF_WAF_MODE"
|
||||
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
||||
trapPaths = "SWWAF_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
||||
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
||||
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
||||
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS"
|
||||
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL"
|
||||
alertNtfyURL = "SWWAF_ALERT_NTFY_URL"
|
||||
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||
alertEvents = "SWWAF_ALERT_EVENTS"
|
||||
alertCooldown = "SWWAF_ALERT_COOLDOWN"
|
||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWWAF_WATCH_NETS"
|
||||
)
|
||||
|
||||
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
||||
// followed by each of the four ends.
|
||||
const (
|
||||
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWW_ANOMALY_NET_"
|
||||
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||
watch = "SWW_WATCH_"
|
||||
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWWAF_ANOMALY_NET_"
|
||||
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
||||
watch = "SWWAF_WATCH_"
|
||||
|
||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||
@@ -157,21 +155,21 @@ func loggedAnomalyDefaults() map[string]string {
|
||||
return logged
|
||||
}
|
||||
|
||||
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
|
||||
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
|
||||
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and
|
||||
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN.
|
||||
const (
|
||||
defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," +
|
||||
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
||||
"source_failure,file_error"
|
||||
defaultAlertCooldown = "15m"
|
||||
)
|
||||
|
||||
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
|
||||
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
||||
const defaultReputationCacheTTL = "24h"
|
||||
|
||||
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
|
||||
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES.
|
||||
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
||||
|
||||
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
|
||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||
"content-type,origin,range"
|
||||
|
||||
@@ -196,10 +194,10 @@ const (
|
||||
otherToken = "fedcba9876543210fedcba9876543210"
|
||||
)
|
||||
|
||||
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and
|
||||
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
|
||||
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
|
||||
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
|
||||
const (
|
||||
instance = "fsn1app1/blog"
|
||||
instance = "fsn1app1/gitea"
|
||||
remoteURL = "syslog+udp://192.0.2.1:514"
|
||||
)
|
||||
|
||||
@@ -209,10 +207,7 @@ const off = "off"
|
||||
// enabled is true, as a setting's value.
|
||||
const enabled = "true"
|
||||
|
||||
// disabled is false, as a setting's value.
|
||||
const disabled = "false"
|
||||
|
||||
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
|
||||
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and
|
||||
// fileSource the source that is the lookup database.
|
||||
const (
|
||||
defaultLookupSource = "geojs"
|
||||
@@ -259,12 +254,12 @@ func TestDefaults(t *testing.T) {
|
||||
AttackBanDuration: 7 * 24 * time.Hour,
|
||||
MaxBans: 5000,
|
||||
BanScopeV4Prefix: 32,
|
||||
StateDir: "/var/lib/sww",
|
||||
StateDir: "/var/lib/smallwebwaf",
|
||||
StateWriteDelay: 10 * time.Second,
|
||||
StateCounterInterval: 15 * time.Minute,
|
||||
MetricsToken: "",
|
||||
MetricsTopN: 50,
|
||||
RulesDir: "/etc/sww/rules.d",
|
||||
RulesDir: "/etc/smallwebwaf/rules.d",
|
||||
RulesEnabled: true,
|
||||
})
|
||||
wantLookupSettings(t, cfg, config.Config{
|
||||
@@ -278,13 +273,9 @@ func TestDefaults(t *testing.T) {
|
||||
wantNetblocks(t, cfg.AllowNets)
|
||||
wantNetblocks(t, cfg.RateLimitExemptNets)
|
||||
wantNetblocks(t, cfg.DenyNets)
|
||||
wantCountries(t, deniedCountries, cfg.DeniedCountries)
|
||||
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()
|
||||
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
||||
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
||||
@@ -335,6 +326,7 @@ func TestValuesAsSet(t *testing.T) {
|
||||
rateLimitPerMinute: "60",
|
||||
rateLimitPerHour: "600",
|
||||
rateLimitPerDay: "6000",
|
||||
deniedCountries: "cn, RU,kp,Xk",
|
||||
allowedCountries: "de",
|
||||
banResponse: "429",
|
||||
limitBanDuration: "15m",
|
||||
@@ -349,7 +341,7 @@ func TestValuesAsSet(t *testing.T) {
|
||||
metricsToken: token,
|
||||
metricsTopN: "10",
|
||||
rulesDir: "/srv/waf-rules",
|
||||
rulesEnabled: disabled,
|
||||
rulesEnabled: "false",
|
||||
})
|
||||
|
||||
wantSettings(t, cfg, config.Config{
|
||||
@@ -377,6 +369,7 @@ func TestValuesAsSet(t *testing.T) {
|
||||
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
||||
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
||||
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
||||
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
|
||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||
}
|
||||
|
||||
@@ -546,14 +539,13 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
||||
environment{
|
||||
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
||||
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
||||
wafBodyLimit: "32K", exclusionsFile: "/etc/sww/crs-exclusions.conf",
|
||||
wafBodyLimit: "32K",
|
||||
},
|
||||
config.Config{
|
||||
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
||||
WAFDisabledRules: []int{942100, 920350},
|
||||
WAFExemptPaths: []string{"/api/", "/static/"},
|
||||
WAFBodyLimit: 32 << 10,
|
||||
CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
|
||||
WAFDisabledRules: []int{942100, 920350},
|
||||
WAFExemptPaths: []string{"/api/", "/static/"},
|
||||
WAFBodyLimit: 32 << 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -573,8 +565,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
||||
WAFMode: cfg.WAFMode, WAFParanoiaLevel: cfg.WAFParanoiaLevel,
|
||||
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
|
||||
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
|
||||
WAFBodyLimit: cfg.WAFBodyLimit,
|
||||
CoreRuleSetExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||
WAFBodyLimit: cfg.WAFBodyLimit,
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
|
||||
@@ -597,7 +588,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
||||
const (
|
||||
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
||||
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
||||
"the Core Rule Set up and of sww's own, which cannot be switched off"
|
||||
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off"
|
||||
)
|
||||
|
||||
for _, tc := range []struct{ name, value, want string }{
|
||||
@@ -636,11 +627,10 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
||||
wafBodyLimit, "128KB",
|
||||
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
||||
},
|
||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` +
|
||||
`gets of a body`},
|
||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most servewaf gets of a body`},
|
||||
{
|
||||
wafBodyLimit, "65537",
|
||||
`"65537" is more than 64K, the most discriminator gets of a body`,
|
||||
`"65537" is more than 64K, the most servewaf gets of a body`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
@@ -660,11 +650,11 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
instanceName: "fsn1app1/blog",
|
||||
instanceName: "fsn1app1/gitea",
|
||||
logRequestHeaders: " Accept , X-Custom",
|
||||
})
|
||||
|
||||
if cfg.InstanceName != "fsn1app1/blog" ||
|
||||
if cfg.InstanceName != "fsn1app1/gitea" ||
|
||||
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
|
||||
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
|
||||
logRequestHeaders, cfg.LogRequestHeaders)
|
||||
@@ -807,7 +797,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
|
||||
|
||||
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
||||
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
||||
`without a space, such as blog`
|
||||
`without a space, such as gitea`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -836,7 +826,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
||||
}.lookupEnv)
|
||||
|
||||
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||
`characters without a space, such as blog`
|
||||
`characters without a space, such as gitea`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -866,7 +856,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
|
||||
const (
|
||||
webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
||||
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||
ntfy = "https://ntfy.example/sww-alerts"
|
||||
ntfy = "https://ntfy.example/smallwebwaf-alerts"
|
||||
token = "tk_0123456789abcdefghijklmnopq"
|
||||
)
|
||||
|
||||
@@ -914,17 +904,17 @@ func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantStartStopped(t, []struct{ name, value string }{
|
||||
{alertWebhookURL, "alerts.example/sww"},
|
||||
{alertWebhookURL, "alerts.example/smallwebwaf"},
|
||||
{alertWebhookURL, "ftp://alerts.example/"},
|
||||
{alertWebhookURL, "https:///sww"},
|
||||
{alertWebhookURL, "https:///smallwebwaf"},
|
||||
{alertWebhookURL, "https://user:password@alerts.example/"},
|
||||
{alertWebhookURL, "https://alerts.example/#top"},
|
||||
{alertWebhookURL, "https://alerts.example:0/"},
|
||||
{alertWebhookURL, "https://alerts.example:65536/"},
|
||||
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
|
||||
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
|
||||
{alertNtfyURL, "ntfy://ntfy.example/sww-alerts"},
|
||||
{alertNtfyURL, "https://ntfy.example/sww-alerts#top"},
|
||||
{alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"},
|
||||
{alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"},
|
||||
{alertWebhookHeaders, "Authorization"},
|
||||
{alertWebhookHeaders, "X Team:ops"},
|
||||
{alertWebhookHeaders, ":ops"},
|
||||
@@ -1004,7 +994,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
||||
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
||||
|
||||
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
||||
"a fragment, such as https://alerts.example/sww"
|
||||
"a fragment, such as https://alerts.example/smallwebwaf"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -1018,7 +1008,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
||||
alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||
alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
||||
alertNtfyToken: token,
|
||||
})
|
||||
|
||||
@@ -1037,7 +1027,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, secret := range []string{"T0123", "sww-alerts", token} {
|
||||
for _, secret := range []string{"T0123", "smallwebwaf-alerts", token} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Errorf("%s in the settings logged: %s", secret, logged)
|
||||
}
|
||||
@@ -1068,7 +1058,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
|
||||
const name = "fsn1app1\r"
|
||||
|
||||
_, err := config.FromEnvironment(environment{
|
||||
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
||||
}.lookupEnv)
|
||||
|
||||
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
||||
@@ -1103,15 +1093,15 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
|
||||
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{
|
||||
tagDeny: "country=cn,country=ru",
|
||||
deniedCountries: "cn,ru",
|
||||
allowedCountries: "de,RU",
|
||||
}.lookupEnv)
|
||||
if err == nil {
|
||||
t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted")
|
||||
t.Fatal("ru on both country lists was accepted")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
||||
@@ -1182,11 +1172,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, value := range map[string]string{
|
||||
deniedCountries: "kp",
|
||||
allowedCountries: "de",
|
||||
addLookupHeaders: enabled,
|
||||
tagDeny: "country=KP",
|
||||
tagLimitPercent: "asn=64496:50",
|
||||
asnLimitPercentURL: asnURL,
|
||||
asnLimitPercent: "AS64496:50",
|
||||
countryLimitPercent: "cn:25",
|
||||
asnBytesPercent: "AS64496:50",
|
||||
countryBytesPercent: "cn:25",
|
||||
unknownLimitPercent: "99",
|
||||
@@ -1209,13 +1200,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
||||
}
|
||||
|
||||
// Set empty, the lists need nothing looked up, and nor does
|
||||
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
|
||||
// lookup does not give, an anomaly threshold per AS number that is off,
|
||||
// any other anomaly threshold, or a blocklist.
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
||||
// threshold per AS number that is off, any other anomaly threshold, or
|
||||
// a blocklist.
|
||||
env := environment{
|
||||
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
|
||||
tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt",
|
||||
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
|
||||
lookupSource: off, deniedCountries: "", allowedCountries: "",
|
||||
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
|
||||
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
||||
blocklistURLs: dropURL,
|
||||
}
|
||||
@@ -1361,251 +1351,34 @@ 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) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
||||
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
|
||||
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
||||
cfg.UnknownLimitPercent != 100 {
|
||||
t.Errorf("biased thresholds %v, %v and %d by default, "+
|
||||
"want two empty lists and 100",
|
||||
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
|
||||
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
|
||||
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
||||
}
|
||||
|
||||
// AS numbers and countries in either case, an AS number with leading
|
||||
// zeros, 0 and 100.
|
||||
cfg = fromEnvironment(t, environment{
|
||||
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
|
||||
countryLimitPercent: "cn:25,RU:50",
|
||||
asnBytesPercent: "as16276:75",
|
||||
countryBytesPercent: "ru:10",
|
||||
unknownLimitPercent: "0",
|
||||
})
|
||||
|
||||
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}},
|
||||
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
||||
} {
|
||||
@@ -1629,16 +1402,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
||||
)
|
||||
|
||||
for _, tc := range []struct{ name, value, want string }{
|
||||
{asnBytesPercent, "14061:50", `"14061"` + notASN},
|
||||
{asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
||||
{asnBytesPercent, "AS14061", `"AS14061"` + notItem},
|
||||
{asnBytesPercent, "AS14061:101", `"101"` + notPercent},
|
||||
{asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
||||
{asnLimitPercent, "14061:50", `"14061"` + notASN},
|
||||
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
||||
{asnLimitPercent, "AS14061", `"AS14061"` + notItem},
|
||||
{asnLimitPercent, "AS14061:101", `"101"` + notPercent},
|
||||
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
||||
{
|
||||
countryBytesPercent, "nk:25",
|
||||
countryLimitPercent, "nk:25",
|
||||
`"nk" is not a two-letter country code such as de or kp`,
|
||||
},
|
||||
{countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
||||
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
||||
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
||||
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
||||
{unknownLimitPercent, "101", `"101"` + notPercent},
|
||||
@@ -1665,7 +1438,7 @@ const (
|
||||
asnURL = "https://lists.example/asn.txt"
|
||||
)
|
||||
|
||||
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
|
||||
// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them.
|
||||
const (
|
||||
actionDeny = "deny"
|
||||
actionLimit = "limit"
|
||||
@@ -1761,7 +1534,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
||||
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
||||
}.lookupEnv)
|
||||
|
||||
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
|
||||
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -2064,7 +1837,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
||||
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
||||
"such as http://172.17.0.1:8080"
|
||||
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
||||
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
|
||||
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too`
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -2226,37 +1999,23 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
||||
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
||||
{addLookupHeaders, "yes"},
|
||||
{allowedCountries, "nk"},
|
||||
{allowedCountries, "kp,,ir"},
|
||||
{allowedCountries, "prk"},
|
||||
{allowedCountries, "408"},
|
||||
{allowedCountries, "k"},
|
||||
{allowedCountries, "eu"},
|
||||
{allowedCountries, "un"},
|
||||
{allowedCountries, "su"},
|
||||
{deniedCountries, "nk"},
|
||||
{deniedCountries, "kp,,ir"},
|
||||
{deniedCountries, "prk"},
|
||||
{deniedCountries, "408"},
|
||||
{deniedCountries, "k"},
|
||||
{deniedCountries, "eu"},
|
||||
{deniedCountries, "un"},
|
||||
{deniedCountries, "su"},
|
||||
{allowedCountries, "ac"},
|
||||
{allowedCountries, "uk"},
|
||||
{allowedCountries, "zz"},
|
||||
{allowedCountries, "de,germany"},
|
||||
{asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
|
||||
{asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
|
||||
{asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
|
||||
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
|
||||
{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"},
|
||||
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
|
||||
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
|
||||
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
|
||||
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
|
||||
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"},
|
||||
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
||||
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
||||
{unknownLimitPercent, "50%"},
|
||||
@@ -2268,25 +2027,6 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
||||
{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) {
|
||||
@@ -2299,7 +2039,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
||||
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
||||
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
||||
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
|
||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||
})
|
||||
@@ -2426,7 +2166,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
||||
}.lookupEnv)
|
||||
|
||||
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||
`characters without a space, such as blog`
|
||||
`characters without a space, such as gitea`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -2541,11 +2281,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
lookupSource: defaultLookupSource,
|
||||
lookupDBPath: "",
|
||||
lookupTimeout: "800ms",
|
||||
addLookupHeaders: disabled,
|
||||
addLookupHeaders: "false",
|
||||
deniedCountries: "",
|
||||
allowedCountries: "",
|
||||
tagLists: "",
|
||||
tagDeny: "",
|
||||
tagLimitPercent: "",
|
||||
asnLimitPercent: "",
|
||||
countryLimitPercent: "",
|
||||
asnBytesPercent: "",
|
||||
countryBytesPercent: "",
|
||||
unknownLimitPercent: "100",
|
||||
@@ -2561,7 +2301,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
reputationAction: "limit:25",
|
||||
reputationCacheTTL: defaultReputationCacheTTL,
|
||||
reputationTimeout: "2s",
|
||||
routeWatchURL: "",
|
||||
crowdSecURL: "",
|
||||
crowdSecKey: "",
|
||||
banResponse: "403",
|
||||
@@ -2571,7 +2310,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
attackBanDuration: "7d",
|
||||
maxBans: "5000",
|
||||
banScopeV4Prefix: "32",
|
||||
stateDir: "/var/lib/sww",
|
||||
stateDir: "/var/lib/smallwebwaf",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
adminToken: "",
|
||||
@@ -2580,7 +2319,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
instanceName: hostname,
|
||||
logRequestHeaders: defaultLogRequestHeaders,
|
||||
logLevel: "info",
|
||||
rulesDir: "/etc/sww/rules.d",
|
||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||
rulesEnabled: "true",
|
||||
wafMode: config.WAFModeBlock,
|
||||
wafParanoiaLevel: "1",
|
||||
@@ -2588,7 +2327,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
wafDisabledRules: defaultWAFDisabledRules,
|
||||
wafExemptPaths: "",
|
||||
wafBodyLimit: off,
|
||||
exclusionsFile: "",
|
||||
trapPaths: "",
|
||||
errorBurstThreshold: "30",
|
||||
logRemoteURL: "",
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
|
||||
// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero
|
||||
// is off.
|
||||
type RProxy struct {
|
||||
// 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
|
||||
// (SWRP_TRUSTED_PROXIES).
|
||||
TrustedProxies []netip.Prefix
|
||||
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
|
||||
// WAFBackend is servewaf's URL (SWRP_WAF_BACKEND).
|
||||
WAFBackend *url.URL
|
||||
// Timeout is how long rproxy waits for discriminator's answer to a question,
|
||||
// Timeout is how long rproxy waits for servewaf's answer to a question,
|
||||
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
||||
Timeout time.Duration
|
||||
// DefaultDisposition is what is done with a request when discriminator cannot be
|
||||
// DefaultDisposition is what is done with a request when servewaf cannot be
|
||||
// connected to, does not answer at all, or does not answer within Timeout:
|
||||
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||
DefaultDisposition string
|
||||
@@ -50,7 +50,7 @@ type RProxy struct {
|
||||
|
||||
var (
|
||||
errNeedsWAFBackend = errors.New(
|
||||
"is required: it is discriminator's URL, such as http://discriminator:8080")
|
||||
"is required: it is servewaf's URL, such as http://servewaf:8080")
|
||||
errNotDisposition = errors.New("is not allow or deny")
|
||||
errNotMilliseconds = errors.New(
|
||||
"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...)
|
||||
}
|
||||
|
||||
// wafBackend reads the setting that is discriminator's URL, which must be set.
|
||||
// wafBackend reads the setting that is servewaf's URL, which must be set.
|
||||
func (e *environment) wafBackend(name string) *url.URL {
|
||||
value := e.value(name, "")
|
||||
if value == "" {
|
||||
@@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL {
|
||||
return backend
|
||||
}
|
||||
|
||||
// disposition reads the setting that is what is done with a request when discriminator
|
||||
// disposition reads the setting that is what is done with a request when servewaf
|
||||
// cannot be connected to, does not answer at all, or does not answer in time:
|
||||
// allow or deny.
|
||||
func (e *environment) disposition(name, defaultValue string) string {
|
||||
|
||||
@@ -24,7 +24,7 @@ const (
|
||||
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||
backendURL = "http://discriminator:8080"
|
||||
backendURL = "http://servewaf:8080"
|
||||
rproxyDefaultAddr = ":8080"
|
||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||
notASize = "a lot"
|
||||
@@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
||||
name, value string
|
||||
}{
|
||||
{wafBackend, ""},
|
||||
{wafBackend, "discriminator:8080"},
|
||||
{wafBackend, "http://discriminator:8080/path"},
|
||||
{wafBackend, "servewaf:8080"},
|
||||
{wafBackend, "http://servewaf:8080/path"},
|
||||
{rproxyTimeout, "0"},
|
||||
{rproxyTimeout, "-1"},
|
||||
{rproxyTimeout, off},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,283 +0,0 @@
|
||||
# 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"
|
||||
-393
@@ -1,393 +0,0 @@
|
||||
# Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
|
||||
# defaults. Each line is a path and query, and may end with one header after a
|
||||
# space. The tests of internal/coreruleset inspect each as a GET. The Core Rule Set reads
|
||||
# the query and the headers the same way for any method.
|
||||
|
||||
# Searches with ordinary words and symbols.
|
||||
/owner/repo/search?q=package.json
|
||||
/owner/repo/search?q=.gitignore
|
||||
/owner/repo/search?q=docker-compose.yml
|
||||
/owner/repo/search?q=python3
|
||||
/owner/repo/search?q=ssh%20key
|
||||
/owner/repo/search?q=feat%2Fdocker-support
|
||||
/owner/repo/search?q=ls%20-la
|
||||
/owner/repo/search?q=sed%20-i
|
||||
/owner/repo/search?q=%2Fbin%2Fsh
|
||||
/owner/repo/search?q=%60make%20check%60
|
||||
/owner/repo/search?q=%60whoami%60
|
||||
/owner/repo/search?q=fetch%28
|
||||
/owner/repo/search?q=%24%7BVAR%7D
|
||||
/owner/repo/search?q=%24%28pwd%29
|
||||
/owner/repo/search?q=process.env
|
||||
/owner/repo/search?q=%3Cimg%20src%3D
|
||||
/owner/repo/search?q=%3Cscript%3E
|
||||
/owner/repo/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/owner/repo/search?q=union%20select
|
||||
/owner/repo/search?q=http%3A%2F%2F127.0.0.1%3A8080
|
||||
/owner/repo/search?q=..%2F
|
||||
/owner/repo/search?q=%2Fetc%2Fpasswd
|
||||
/owner/repo/search?q=eval%28
|
||||
/owner/repo/search?q=chmod%20%2Bx
|
||||
/owner/repo/search?q=echo%20%24PATH
|
||||
/owner/repo/search?q=java.lang.Runtime
|
||||
/owner/repo/search?q=document.cookie
|
||||
/owner/repo/search?q=alert%281%29
|
||||
/owner/repo/search?q=__proto__
|
||||
/owner/repo/search?q=C%3A%5CWindows%5CSystem32
|
||||
/owner/repo/search?q=Invoke-WebRequest
|
||||
/owner/repo/search?q=if%20%28a%20%3D%3D%20b%29%20%7B
|
||||
/owner/repo/search?q=sudo%20apt%20install
|
||||
/owner/repo/search?q=~%2F.ssh%2Fconfig
|
||||
/owner/repo/search?q=.htaccess
|
||||
/owner/repo/search?q=%24_GET%5B
|
||||
/owner/repo/search?q=%24_POST
|
||||
/owner/repo/search?q=%24_SERVER%5B%27REQUEST_URI%27%5D
|
||||
/owner/repo/search?q=%3C%3Fphp%20echo
|
||||
/owner/repo/search?q=information_schema
|
||||
/owner/repo/search?q=LIKE%20%27%25foo%25%27
|
||||
/owner/repo/search?q=onClick%3D%7B
|
||||
/explore/code?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/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=%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=%3Cimg%20src%3D
|
||||
/issues?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/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=%3Cimg%20src%3D
|
||||
/api/v1/repos/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/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=%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
|
||||
# contents.
|
||||
/owner/repo/src/branch/main/package.json
|
||||
/owner/repo/raw/branch/main/package.json
|
||||
/owner/repo/_edit/main/package.json
|
||||
/api/v1/repos/owner/repo/contents/package.json
|
||||
/owner/repo/src/branch/main/.gitignore
|
||||
/owner/repo/raw/branch/main/.gitignore
|
||||
/owner/repo/_edit/main/.gitignore
|
||||
/api/v1/repos/owner/repo/contents/.gitignore
|
||||
/owner/repo/src/branch/main/docker-compose.yml
|
||||
/owner/repo/raw/branch/main/docker-compose.yml
|
||||
/owner/repo/_edit/main/docker-compose.yml
|
||||
/api/v1/repos/owner/repo/contents/docker-compose.yml
|
||||
/owner/repo/src/branch/main/Dockerfile
|
||||
/owner/repo/raw/branch/main/Dockerfile
|
||||
/owner/repo/_edit/main/Dockerfile
|
||||
/api/v1/repos/owner/repo/contents/Dockerfile
|
||||
/owner/repo/src/branch/main/.htaccess
|
||||
/owner/repo/raw/branch/main/.htaccess
|
||||
/owner/repo/_edit/main/.htaccess
|
||||
/api/v1/repos/owner/repo/contents/.htaccess
|
||||
/owner/repo/src/branch/main/rootfs/etc/passwd
|
||||
/owner/repo/raw/branch/main/rootfs/etc/passwd
|
||||
/owner/repo/_edit/main/rootfs/etc/passwd
|
||||
/api/v1/repos/owner/repo/contents/rootfs/etc/passwd
|
||||
/owner/repo/src/branch/main/bin/docker-entrypoint
|
||||
/owner/repo/raw/branch/main/bin/docker-entrypoint
|
||||
/owner/repo/_edit/main/bin/docker-entrypoint
|
||||
/api/v1/repos/owner/repo/contents/bin/docker-entrypoint
|
||||
/owner/repo/src/branch/main/scripts/python3-setup.sh
|
||||
/owner/repo/raw/branch/main/scripts/python3-setup.sh
|
||||
/owner/repo/_edit/main/scripts/python3-setup.sh
|
||||
/api/v1/repos/owner/repo/contents/scripts/python3-setup.sh
|
||||
/owner/repo/src/branch/main/src/java.base/share/classes/java/lang/Runtime.java
|
||||
/owner/repo/raw/branch/main/src/java.base/share/classes/java/lang/Runtime.java
|
||||
/owner/repo/_edit/main/src/java.base/share/classes/java/lang/Runtime.java
|
||||
/api/v1/repos/owner/repo/contents/src/java.base/share/classes/java/lang/Runtime.java
|
||||
/owner/repo/src/branch/main/java.lang.String.md
|
||||
/owner/repo/raw/branch/main/java.lang.String.md
|
||||
/owner/repo/_edit/main/java.lang.String.md
|
||||
/api/v1/repos/owner/repo/contents/java.lang.String.md
|
||||
/owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
|
||||
/owner/repo/raw/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
|
||||
/owner/repo/_edit/main/META-INF/services/org.apache.commons.logging.LogFactory
|
||||
/api/v1/repos/owner/repo/contents/META-INF/services/org.apache.commons.logging.LogFactory
|
||||
/owner/repo/src/branch/main/process.env.d.ts
|
||||
/owner/repo/raw/branch/main/process.env.d.ts
|
||||
/owner/repo/_edit/main/process.env.d.ts
|
||||
/api/v1/repos/owner/repo/contents/process.env.d.ts
|
||||
/owner/repo/src/branch/main/document.write.js
|
||||
/owner/repo/raw/branch/main/document.write.js
|
||||
/owner/repo/_edit/main/document.write.js
|
||||
/api/v1/repos/owner/repo/contents/document.write.js
|
||||
/owner/repo/src/branch/main/test/fixtures/__proto__.json
|
||||
/owner/repo/raw/branch/main/test/fixtures/__proto__.json
|
||||
/owner/repo/_edit/main/test/fixtures/__proto__.json
|
||||
/api/v1/repos/owner/repo/contents/test/fixtures/__proto__.json
|
||||
/owner/repo/src/branch/main/install-script.sh
|
||||
/owner/repo/raw/branch/main/install-script.sh
|
||||
/owner/repo/_edit/main/install-script.sh
|
||||
/api/v1/repos/owner/repo/contents/install-script.sh
|
||||
/owner/repo/src/branch/main/src/backend/catalog/information_schema.sql
|
||||
/owner/repo/raw/branch/main/src/backend/catalog/information_schema.sql
|
||||
/owner/repo/_edit/main/src/backend/catalog/information_schema.sql
|
||||
/api/v1/repos/owner/repo/contents/src/backend/catalog/information_schema.sql
|
||||
/owner/repo/src/branch/main/docs/a%20b.xhtml
|
||||
/owner/repo/raw/branch/main/docs/a%20b.xhtml
|
||||
/owner/repo/_edit/main/docs/a%20b.xhtml
|
||||
/api/v1/repos/owner/repo/contents/docs/a%20b.xhtml
|
||||
/owner/repo/src/branch/main/%5Bdraft%5D.md
|
||||
/owner/repo/raw/branch/main/%5Bdraft%5D.md
|
||||
/owner/repo/_edit/main/%5Bdraft%5D.md
|
||||
/api/v1/repos/owner/repo/contents/%5Bdraft%5D.md
|
||||
/owner/repo/src/branch/main/notes.txt~
|
||||
/owner/repo/raw/branch/main/notes.txt~
|
||||
/owner/repo/_edit/main/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
|
||||
# of its commits and of its contents, an issue opened from a template, the
|
||||
# sign-in page on the way to its edit page, and a diff's hidden lines.
|
||||
/owner/repo/graph?file=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
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fpackage.json
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fpackage.json
|
||||
/owner/repo/graph?file=.gitignore
|
||||
/api/v1/repos/owner/repo/commits?path=.gitignore
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.gitignore%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.gitignore
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.gitignore
|
||||
/owner/repo/graph?file=docker-compose.yml
|
||||
/api/v1/repos/owner/repo/commits?path=docker-compose.yml
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docker-compose.yml%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocker-compose.yml
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocker-compose.yml
|
||||
/owner/repo/graph?file=.htaccess
|
||||
/api/v1/repos/owner/repo/commits?path=.htaccess
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.htaccess%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.htaccess
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.htaccess
|
||||
/owner/repo/graph?file=rootfs%2Fetc%2Fpasswd
|
||||
/api/v1/repos/owner/repo/commits?path=rootfs%2Fetc%2Fpasswd
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22rootfs%2Fetc%2Fpasswd%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Frootfs%2Fetc%2Fpasswd
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Frootfs%2Fetc%2Fpasswd
|
||||
/owner/repo/graph?file=bin%2Fdocker-entrypoint
|
||||
/api/v1/repos/owner/repo/commits?path=bin%2Fdocker-entrypoint
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22bin%2Fdocker-entrypoint%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fbin%2Fdocker-entrypoint
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fbin%2Fdocker-entrypoint
|
||||
/owner/repo/graph?file=scripts%2Fpython3-setup.sh
|
||||
/api/v1/repos/owner/repo/commits?path=scripts%2Fpython3-setup.sh
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22scripts%2Fpython3-setup.sh%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fscripts%2Fpython3-setup.sh
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fscripts%2Fpython3-setup.sh
|
||||
/owner/repo/graph?file=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
|
||||
/api/v1/repos/owner/repo/commits?path=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
|
||||
/owner/repo/graph?file=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/commits?path=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22java.lang.String.md%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fjava.lang.String.md
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fjava.lang.String.md
|
||||
/owner/repo/graph?file=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
|
||||
/api/v1/repos/owner/repo/commits?path=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
|
||||
/owner/repo/graph?file=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/commits?path=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22process.env.d.ts%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fprocess.env.d.ts
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fprocess.env.d.ts
|
||||
/owner/repo/graph?file=document.write.js
|
||||
/api/v1/repos/owner/repo/commits?path=document.write.js
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22document.write.js%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocument.write.js
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocument.write.js
|
||||
/owner/repo/graph?file=test%2Ffixtures%2F__proto__.json
|
||||
/api/v1/repos/owner/repo/commits?path=test%2Ffixtures%2F__proto__.json
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22test%2Ffixtures%2F__proto__.json%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Ftest%2Ffixtures%2F__proto__.json
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Ftest%2Ffixtures%2F__proto__.json
|
||||
/owner/repo/graph?file=install-script.sh
|
||||
/api/v1/repos/owner/repo/commits?path=install-script.sh
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22install-script.sh%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Finstall-script.sh
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Finstall-script.sh
|
||||
/owner/repo/graph?file=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
|
||||
/api/v1/repos/owner/repo/commits?path=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fbackend%2Fcatalog%2Finformation_schema.sql%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
|
||||
/owner/repo/graph?file=docs%2Fa%20b.xhtml
|
||||
/api/v1/repos/owner/repo/commits?path=docs%2Fa%20b.xhtml
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docs%2Fa%20b.xhtml%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocs%2Fa%20b.xhtml
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocs%2Fa%20b.xhtml
|
||||
/owner/repo/graph?file=%5Bdraft%5D.md
|
||||
/api/v1/repos/owner/repo/commits?path=%5Bdraft%5D.md
|
||||
/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
|
||||
/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
|
||||
# a comment.
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=package.json
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=.gitignore
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=docker-compose.yml
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=.htaccess
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=docker-entrypoint
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=python3-setup.sh
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=Runtime.java
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=org.apache.commons.logging.LogFactory
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=%5Bdraft%5D.md
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=notes.txt~
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=install-script.sh
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=install-script.sh
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=install-script.sh
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=information_schema.sql
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=information_schema.sql
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=information_schema.sql
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=document.write.js
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=document.write.js
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=document.write.js
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=__proto__.json
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=__proto__.json
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=__proto__.json
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=index.xhtml
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=index.xhtml
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=index.xhtml
|
||||
|
||||
# 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
|
||||
|
||||
# Branch and tag names in the query string: deleting and restoring a branch,
|
||||
# starting a release from a tag, a branch protection rule, a directory listing's
|
||||
# last commits, and a container image push that reuses a layer.
|
||||
/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/settings/branches/edit?rule_name=docker-build
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fdocker-build
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fdocker-build
|
||||
/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/settings/branches/edit?rule_name=feat%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
|
||||
/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/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
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Frenovate%2Fdocker-build-push-action-6.x
|
||||
/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/settings/branches/edit?rule_name=python3-migration
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fpython3-migration
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fpython3-migration
|
||||
/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/settings/branches/edit?rule_name=ansible-roles
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fansible-roles
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fansible-roles
|
||||
/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/settings/branches/edit?rule_name=whoami
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fwhoami
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fwhoami
|
||||
|
||||
# Pages a signed-in user opens.
|
||||
/
|
||||
/owner/repo
|
||||
/owner/repo/issues
|
||||
/owner/repo/pulls
|
||||
/owner/repo/releases
|
||||
/owner/repo/wiki
|
||||
/owner/repo/activity
|
||||
/owner/repo/branches
|
||||
/owner/repo/tags
|
||||
/owner/repo/settings
|
||||
/owner/repo/actions
|
||||
/owner/repo/compare/main...main
|
||||
/owner/repo/commits/branch/main
|
||||
/owner/repo/archive/main.zip
|
||||
/notifications
|
||||
/user/settings
|
||||
/user/settings/applications
|
||||
/explore/repos
|
||||
/-/admin
|
||||
@@ -1,403 +0,0 @@
|
||||
# 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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\"}"}
|
||||
@@ -1,223 +0,0 @@
|
||||
# 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"
|
||||
|
||||
# WordPress's searches for posts, media and plugins, and the later pages of a
|
||||
# search of the site, send the text searched for in s, which skips these rules
|
||||
# there:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
SecRule REQUEST_FILENAME \
|
||||
"@rx ^/(?:(?:index\.php/)?page/[0-9]+/?|index\.php|wp-admin/(?:edit|upload|plugin-install)\.php)?$" \
|
||||
"id:900707,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:s,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:s,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:s,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:s"
|
||||
|
||||
# The REST API's searches send it in search, on every route of WordPress's own
|
||||
# API, which skips the same rules there.
|
||||
SecRule TX:rest_route "@beginsWith /wp/v2/" \
|
||||
"id:900708,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:search,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:search,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:search,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:search"
|
||||
|
||||
# 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"
|
||||
|
||||
# The plugin file editor and the links to activate, deactivate and install a
|
||||
# plugin name it in plugin, which skips these rules there:
|
||||
# 932120: powershell-tools/powershell-tools.php
|
||||
# 932260: docker-helper/docker-helper.php
|
||||
SecRule REQUEST_FILENAME "@rx ^/wp-admin/(?:plugin-editor|plugins|update)\.php$" \
|
||||
"id:900710,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:plugin,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:plugin"
|
||||
|
||||
# The list pages of wp-admin send their own address in _wp_http_referer, so the
|
||||
# next search, filter or bulk action repeats the last search, which skips these
|
||||
# rules there:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# 932235: python3
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
# 932340: &ps, the end of the media list's own address
|
||||
SecRule REQUEST_FILENAME \
|
||||
"@rx ^/wp-admin/(?:edit|edit-comments|edit-tags|plugin-install|plugins|theme-install|themes|upload|users)\.php$" \
|
||||
"id:900711,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:_wp_http_referer,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:_wp_http_referer,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:_wp_http_referer,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:_wp_http_referer,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:_wp_http_referer,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer"
|
||||
|
||||
# The rules below refused the text searched for, or a name:
|
||||
# 932120: powershell
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
# Each request skips them in the field that holds that text.
|
||||
|
||||
# wp-admin's searches of comments, users, plugins and tags, in s.
|
||||
SecRule REQUEST_FILENAME "@rx ^/wp-admin/(?:edit-comments|users|plugins|edit-tags)\.php$" \
|
||||
"id:900712,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:s,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:s,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:s"
|
||||
|
||||
# wp-admin's searches of themes and of the media library's grid, in search.
|
||||
SecRule REQUEST_FILENAME "@rx ^/wp-admin/(?:themes|theme-install|upload)\.php$" \
|
||||
"id:900713,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:search,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:search,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:search"
|
||||
|
||||
# The tag field of the classic editor, in q, and the search for a user to add,
|
||||
# in term.
|
||||
SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
|
||||
"id:900714,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:term,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:term,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:term"
|
||||
|
||||
# The REST API's search of the block directory, in term.
|
||||
SecRule TX:rest_route "@streq /wp/v2/block-directory/search" \
|
||||
"id:900715,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:term,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:term,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:term"
|
||||
|
||||
# A tag's address with the Plain permalink setting, and the link on a tag in the
|
||||
# list of posts, in tag.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php|wp-admin/edit\.php)?$" \
|
||||
"id:900716,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:tag,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:tag,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:tag"
|
||||
|
||||
# The link on a category in the list of posts, in category_name.
|
||||
SecRule REQUEST_FILENAME "@streq /wp-admin/edit.php" \
|
||||
"id:900717,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:category_name,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:category_name,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:category_name"
|
||||
@@ -1,129 +0,0 @@
|
||||
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,35 +0,0 @@
|
||||
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,50 +0,0 @@
|
||||
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,42 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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")
|
||||
}
|
||||
+8
-18
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
|
||||
// FileParams are what OpenFile needs.
|
||||
type FileParams struct {
|
||||
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||
// (SWW_LOOKUP_DB_PATH).
|
||||
// (SWWAF_LOOKUP_DB_PATH).
|
||||
Path string
|
||||
// Now tells the time, normally time.Now.
|
||||
Now func() time.Time
|
||||
@@ -51,7 +51,7 @@ type File struct {
|
||||
}
|
||||
|
||||
// record is what the lookup database holds about a network, of the fields
|
||||
// sww reads.
|
||||
// smallwebwaf reads.
|
||||
type record struct {
|
||||
ASN string `maxminddb:"asn"`
|
||||
ASName string `maxminddb:"as_name"`
|
||||
@@ -133,11 +133,8 @@ func (f *File) Watch(ctx context.Context) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error(
|
||||
"cannot watch the lookup database for replacements",
|
||||
"error",
|
||||
err.Error(),
|
||||
)
|
||||
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
||||
"error", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
@@ -197,10 +194,7 @@ func (f *File) readAgain() {
|
||||
f.params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventFileError,
|
||||
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())
|
||||
|
||||
@@ -218,11 +212,7 @@ func (f *File) use(reader *maxminddb.Reader) {
|
||||
f.lastRead = f.params.Now()
|
||||
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
|
||||
@@ -231,12 +221,12 @@ func (f *File) use(reader *maxminddb.Reader) {
|
||||
func read(path string) (*maxminddb.Reader, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||
}
|
||||
|
||||
reader, err := maxminddb.OpenBytes(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
|
||||
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
||||
writeFile(t, notDatabase, "not a lookup database\n")
|
||||
|
||||
for path, want := range map[string]string{
|
||||
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||
": no such file or directory",
|
||||
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
|
||||
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase +
|
||||
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||
} {
|
||||
_, 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",
|
||||
Detail: map[string]any{
|
||||
"file": path,
|
||||
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||
"error opening database: invalid MaxMind DB file",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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
|
||||
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||
// SWWAF_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
|
||||
// lookups.json and read from it by the state package.
|
||||
package lookup
|
||||
@@ -66,7 +66,7 @@ type Params struct {
|
||||
URL string
|
||||
// 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
|
||||
// (SWW_LOOKUP_TIMEOUT).
|
||||
// (SWWAF_LOOKUP_TIMEOUT).
|
||||
Timeout time.Duration
|
||||
// Wait is true when a setting needs each request's answer before the
|
||||
// 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)
|
||||
if !found {
|
||||
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||
g.metrics.GeoJSUnanswered.Inc()
|
||||
}
|
||||
|
||||
w, waiting := g.waiting[client]
|
||||
@@ -296,7 +296,7 @@ func (g *GeoJS) answerOrWait(
|
||||
}
|
||||
|
||||
if w == nil {
|
||||
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||
g.metrics.GeoJSUnanswered.Inc()
|
||||
|
||||
return Answer{}, nil // too many clients wait already
|
||||
}
|
||||
@@ -307,7 +307,7 @@ func (g *GeoJS) answerOrWait(
|
||||
}
|
||||
|
||||
if w.late {
|
||||
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||
g.metrics.GeoJSUnanswered.Inc()
|
||||
|
||||
return Answer{}, nil
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
|
||||
mu.Unlock()
|
||||
|
||||
wantCountry(t, g, client, germany)
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 0)
|
||||
wantUnanswered(t, m, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -596,7 +596,7 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
||||
// 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.
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 1)
|
||||
wantUnanswered(t, m, 1)
|
||||
|
||||
// Meanwhile each new client goes without one at once, while there is
|
||||
// room for it among the 10,000 that may wait.
|
||||
@@ -604,11 +604,11 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 10000)
|
||||
wantUnanswered(t, m, 10000)
|
||||
|
||||
// One more, for which there is no room, goes without one too.
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 10001)
|
||||
wantUnanswered(t, m, 10001)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -841,12 +841,12 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
// wantRequestsWithoutGeoJSAnswer checks how many requests m counts as having
|
||||
// gone without an answer from GeoJS.
|
||||
func wantRequestsWithoutGeoJSAnswer(t *testing.T, m *metrics.Metrics, want float64) {
|
||||
// wantUnanswered checks how many requests m counts as having gone without
|
||||
// an answer from GeoJS.
|
||||
func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) {
|
||||
t.Helper()
|
||||
|
||||
got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer)
|
||||
got := testutil.ToFloat64(m.GeoJSUnanswered)
|
||||
if got != want {
|
||||
t.Errorf("%v requests went without an answer, want %v", got, want)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
const fileMode = 0o600
|
||||
|
||||
// Network is what a lookup database holds about a netblock, of the fields
|
||||
// sww reads: its AS number, such as AS64496, the AS's name, and
|
||||
// smallwebwaf reads: its AS number, such as AS64496, the AS's name, and
|
||||
// its country, such as DE.
|
||||
type Network struct {
|
||||
ASN string
|
||||
|
||||
@@ -25,7 +25,7 @@ type busiest struct {
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
responseBytes *prometheus.CounterVec
|
||||
// refused are, by country, the requests the country list refused; nil
|
||||
// refused are, by country, the requests the country lists refused; nil
|
||||
// by AS number.
|
||||
refused *prometheus.CounterVec
|
||||
|
||||
@@ -41,8 +41,8 @@ type busiest struct {
|
||||
// for the topN busiest countries.
|
||||
func newCountries(topN int) *busiest {
|
||||
countries := newBusiest(topN, "country", "the client's country")
|
||||
countries.refused = counterVec("sww_country_list_refusals_total",
|
||||
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
||||
countries.refused = counterVec("smallwebwaf_country_list_refusals_total",
|
||||
"Requests the country lists refused, by the client's country.",
|
||||
[]string{"country"})
|
||||
|
||||
return countries
|
||||
@@ -61,11 +61,11 @@ func newBusiest(topN int, label, description string) *busiest {
|
||||
|
||||
return &busiest{
|
||||
topN: topN,
|
||||
requests: counterVec("sww_"+label+"_requests_total",
|
||||
requests: counterVec("smallwebwaf_"+label+"_requests_total",
|
||||
"Requests, by "+description+".", by),
|
||||
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
||||
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total",
|
||||
"Request body bytes, by "+description+".", by),
|
||||
responseBytes: counterVec("sww_"+label+"_response_bytes_total",
|
||||
responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total",
|
||||
"Response body bytes, by "+description+".", by),
|
||||
seen: map[string]int64{},
|
||||
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
|
||||
// country, the refusals by the country list as well.
|
||||
// country, the refusals by the country lists as well.
|
||||
func (b *busiest) vecs() []*prometheus.CounterVec {
|
||||
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
||||
if b.refused != nil {
|
||||
|
||||
+77
-77
@@ -1,4 +1,4 @@
|
||||
// Package metrics keeps sww's Prometheus metrics, as
|
||||
// Package metrics keeps smallwebwaf's Prometheus metrics, as
|
||||
// docs/metrics.md lists them, and serves them in the Prometheus text
|
||||
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
||||
// metric carries a client's address.
|
||||
@@ -22,21 +22,21 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// Metrics are sww's metrics. They are safe for concurrent use.
|
||||
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
||||
type Metrics struct {
|
||||
// registry gives every metric registered with it the label instance.
|
||||
registry prometheus.Registerer
|
||||
handler http.Handler
|
||||
|
||||
inFlight prometheus.Gauge
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
responseBytes *prometheus.CounterVec
|
||||
requestDuration prometheus.Histogram
|
||||
upstreamDuration prometheus.Histogram
|
||||
rateLimitHits *prometheus.CounterVec
|
||||
offences *prometheus.CounterVec
|
||||
coreRuleSetMatches *prometheus.CounterVec
|
||||
inFlight prometheus.Gauge
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
responseBytes *prometheus.CounterVec
|
||||
requestDuration prometheus.Histogram
|
||||
upstreamDuration prometheus.Histogram
|
||||
rateLimitHits *prometheus.CounterVec
|
||||
offences *prometheus.CounterVec
|
||||
wafMatches *prometheus.CounterVec
|
||||
// ruleMatches are made by AddRules, and reputationHits by
|
||||
// AddReputation.
|
||||
ruleMatches *prometheus.CounterVec
|
||||
@@ -45,12 +45,12 @@ type Metrics struct {
|
||||
asns *busiest
|
||||
|
||||
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
||||
// that failed. RequestsWithoutGeoJSAnswer are the requests that needed
|
||||
// their client's answer, for a setting that acts on it, and went on
|
||||
// without it because GeoJS had not given it in time.
|
||||
GeoJSRequests prometheus.Counter
|
||||
GeoJSFailures prometheus.Counter
|
||||
RequestsWithoutGeoJSAnswer prometheus.Counter
|
||||
// that failed. GeoJSUnanswered are the requests that needed their
|
||||
// client's answer, for a setting that acts on it, and went on without
|
||||
// it because GeoJS had not given it in time.
|
||||
GeoJSRequests prometheus.Counter
|
||||
GeoJSFailures prometheus.Counter
|
||||
GeoJSUnanswered prometheus.Counter
|
||||
|
||||
stateFileWrites *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.
|
||||
// topN is how many countries and how many AS numbers get series of their
|
||||
// own (SWW_METRICS_TOP_N). Every metric carries instanceName
|
||||
// (SWW_INSTANCE_NAME) as its label instance.
|
||||
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
||||
// (SWWAF_INSTANCE_NAME) as its label instance.
|
||||
//
|
||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||
func New(topN int, instanceName string) *Metrics {
|
||||
@@ -76,60 +76,60 @@ func New(topN int, instanceName string) *Metrics {
|
||||
prometheus.Labels{"instance": instanceName}, registry),
|
||||
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
||||
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "sww_requests_in_flight",
|
||||
Name: "smallwebwaf_requests_in_flight",
|
||||
Help: "Requests under way.",
|
||||
}),
|
||||
requests: counterVec("sww_requests_total",
|
||||
requests: counterVec("smallwebwaf_requests_total",
|
||||
"Requests, by the class of their status and their action.", byStatus),
|
||||
requestBytes: counterVec("sww_request_bytes_total",
|
||||
requestBytes: counterVec("smallwebwaf_request_bytes_total",
|
||||
"Request body bytes, by the class of the status and the action.",
|
||||
byStatus),
|
||||
responseBytes: counterVec("sww_response_bytes_total",
|
||||
responseBytes: counterVec("smallwebwaf_response_bytes_total",
|
||||
"Response body bytes, by the class of the status and the action.",
|
||||
byStatus),
|
||||
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "sww_request_duration_seconds",
|
||||
Name: "smallwebwaf_request_duration_seconds",
|
||||
Help: "How long requests took, from their arrival to their end.",
|
||||
}),
|
||||
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "sww_upstream_duration_seconds",
|
||||
Name: "smallwebwaf_upstream_duration_seconds",
|
||||
Help: "How long requests passed to the app took, from then to their end.",
|
||||
}),
|
||||
rateLimitHits: counterVec("sww_rate_limit_hits_total",
|
||||
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total",
|
||||
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
||||
"its window and its kind, requests, bytes or refusals.",
|
||||
[]string{"window", "kind"}),
|
||||
offences: counterVec("sww_offences_total",
|
||||
offences: counterVec("smallwebwaf_offences_total",
|
||||
"Offences, by kind.", []string{"kind"}),
|
||||
coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
|
||||
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
|
||||
wafMatches: counterVec("smallwebwaf_waf_matches_total",
|
||||
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+
|
||||
"and the rule's id.", []string{"mode", "rule_id"}),
|
||||
countries: newCountries(topN),
|
||||
asns: newASNs(topN),
|
||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sww_geojs_requests_total",
|
||||
Name: "smallwebwaf_geojs_requests_total",
|
||||
Help: "Requests to GeoJS.",
|
||||
}),
|
||||
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sww_geojs_failures_total",
|
||||
Name: "smallwebwaf_geojs_failures_total",
|
||||
Help: "Requests to GeoJS that failed.",
|
||||
}),
|
||||
RequestsWithoutGeoJSAnswer: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sww_requests_without_geojs_answer_total",
|
||||
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "smallwebwaf_geojs_unanswered_total",
|
||||
Help: "Requests that needed their client's answer from GeoJS and " +
|
||||
"went on without it, because GeoJS had not given it in time.",
|
||||
}),
|
||||
stateFileWrites: counterVec("sww_state_file_writes_total",
|
||||
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
||||
"Writes of each state file.", byFile),
|
||||
stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
|
||||
stateFileWriteFailures: counterVec("smallwebwaf_state_file_write_failures_total",
|
||||
"Writes of each state file that failed.", byFile),
|
||||
stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
|
||||
stateFileLastWrite: gaugeVec("smallwebwaf_state_file_last_write_timestamp_seconds",
|
||||
"When each state file was last written, in seconds since 1970.", byFile),
|
||||
stateFileSize: gaugeVec("sww_state_file_size_bytes",
|
||||
stateFileSize: gaugeVec("smallwebwaf_state_file_size_bytes",
|
||||
"The size of each state file, as it was last written.", byFile),
|
||||
stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
|
||||
stateFileEditsTakenIn: counterVec("smallwebwaf_state_file_edits_taken_in_total",
|
||||
"Edits of each state file taken in while running.", byFile),
|
||||
stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
|
||||
stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total",
|
||||
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
||||
byFile),
|
||||
}
|
||||
@@ -139,9 +139,9 @@ func New(topN int, instanceName string) *Metrics {
|
||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
||||
m.requestDuration, m.upstreamDuration,
|
||||
m.rateLimitHits, m.offences, m.coreRuleSetMatches,
|
||||
m.rateLimitHits, m.offences, m.wafMatches,
|
||||
m.countries, m.asns,
|
||||
m.GeoJSRequests, m.GeoJSFailures, m.RequestsWithoutGeoJSAnswer,
|
||||
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
||||
m.stateFileWrites, m.stateFileWriteFailures,
|
||||
m.stateFileLastWrite, m.stateFileSize,
|
||||
m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
|
||||
@@ -161,7 +161,7 @@ func (m *Metrics) AddBansAndClients(
|
||||
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
||||
} {
|
||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_bans_made_total",
|
||||
Name: "smallwebwaf_bans_made_total",
|
||||
Help: "Bans made, by cause.",
|
||||
ConstLabels: prometheus.Labels{"cause": cause},
|
||||
}, func() float64 {
|
||||
@@ -171,7 +171,7 @@ func (m *Metrics) AddBansAndClients(
|
||||
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_active_bans",
|
||||
Name: "smallwebwaf_active_bans",
|
||||
Help: "Bans active now, the permanent ones included.",
|
||||
}, func() float64 {
|
||||
active, _ := ledger.Count(now())
|
||||
@@ -179,7 +179,7 @@ func (m *Metrics) AddBansAndClients(
|
||||
return float64(active)
|
||||
}),
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_permanent_bans",
|
||||
Name: "smallwebwaf_permanent_bans",
|
||||
Help: "Permanent bans not lifted.",
|
||||
}, func() float64 {
|
||||
_, permanent := ledger.Count(now())
|
||||
@@ -187,7 +187,7 @@ func (m *Metrics) AddBansAndClients(
|
||||
return float64(permanent)
|
||||
}),
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_tracked_clients",
|
||||
Name: "smallwebwaf_tracked_clients",
|
||||
Help: "Clients in the table of clients.",
|
||||
}, func() float64 {
|
||||
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
|
||||
// RuleMatched.
|
||||
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
||||
m.ruleMatches = counterVec("sww_rule_matches_total",
|
||||
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total",
|
||||
"Requests that matched a rule of the rule files, by its id and action.",
|
||||
[]string{"rule_id", "action"})
|
||||
|
||||
m.registry.MustRegister(m.ruleMatches,
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_rules_loaded",
|
||||
Name: "smallwebwaf_rules_loaded",
|
||||
Help: "Rules loaded from the rule files.",
|
||||
}, func() float64 {
|
||||
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
|
||||
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||
// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||
// lines sent, those dropped, and those waiting in the buffer.
|
||||
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_remote_log_lines_sent_total",
|
||||
Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
|
||||
Name: "smallwebwaf_remote_log_lines_sent_total",
|
||||
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.",
|
||||
}, func() float64 {
|
||||
return float64(remote.Sent())
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_remote_log_lines_dropped_total",
|
||||
Name: "smallwebwaf_remote_log_lines_dropped_total",
|
||||
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
||||
"whose sending failed.",
|
||||
}, func() float64 {
|
||||
return float64(remote.Dropped())
|
||||
}),
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_remote_log_lines_waiting",
|
||||
Name: "smallwebwaf_remote_log_buffer_depth",
|
||||
Help: "Log lines in the buffer, waiting to be sent.",
|
||||
}, func() float64 {
|
||||
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) {
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_lookup_database_last_read_timestamp_seconds",
|
||||
Name: "smallwebwaf_lookup_database_last_read_timestamp_seconds",
|
||||
Help: "When the lookup database in use was read, in seconds since 1970.",
|
||||
}, func() float64 {
|
||||
return float64(lastRead().Unix())
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_lookup_database_read_failures_total",
|
||||
Name: "smallwebwaf_lookup_database_read_failures_total",
|
||||
Help: "Replacements of the lookup database that could not be read.",
|
||||
}, func() float64 {
|
||||
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
|
||||
// failed. It is called once, before ReputationHit.
|
||||
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
||||
m.reputationHits = counterVec("sww_reputation_hits_total",
|
||||
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
|
||||
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
||||
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
||||
[]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.registry.MustRegister(
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_reputation_last_fetch_timestamp_seconds",
|
||||
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
||||
Help: "When the copy of the list in use was fetched, in seconds since " +
|
||||
"1970, or 0 while there is none.",
|
||||
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,
|
||||
// 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
|
||||
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
|
||||
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set.
|
||||
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
||||
|
||||
@@ -321,8 +321,8 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||
m.addReputationFailures(source, abuseIPDB.Failures)
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_reputation_daily_budget_remaining",
|
||||
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||
Name: "smallwebwaf_reputation_daily_budget_remaining",
|
||||
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||
ConstLabels: source,
|
||||
}, func() float64 {
|
||||
return float64(abuseIPDB.BudgetLeft())
|
||||
@@ -348,29 +348,29 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
|
||||
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_alerts_sent_total",
|
||||
Name: "smallwebwaf_alerts_sent_total",
|
||||
Help: "Alerts the destination took.",
|
||||
ConstLabels: destination,
|
||||
}, func() float64 {
|
||||
return float64(queue.Counts(name).Sent)
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_alert_send_failures_total",
|
||||
Name: "smallwebwaf_alerts_failed_total",
|
||||
Help: "Requests to the destination that failed.",
|
||||
ConstLabels: destination,
|
||||
}, func() float64 {
|
||||
return float64(queue.Counts(name).SendFailures)
|
||||
return float64(queue.Counts(name).Failed)
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_alerts_suppressed_total",
|
||||
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
|
||||
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||
Name: "smallwebwaf_alerts_suppressed_total",
|
||||
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " +
|
||||
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||
ConstLabels: destination,
|
||||
}, func() float64 {
|
||||
return float64(queue.Suppressed())
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_alerts_dropped_total",
|
||||
Name: "smallwebwaf_alerts_dropped_total",
|
||||
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
||||
"alerts given up as the destination refused them.",
|
||||
ConstLabels: destination,
|
||||
@@ -424,10 +424,10 @@ func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
||||
// them.
|
||||
func (m *Metrics) Offences(r ratelimit.Request) {
|
||||
for kind, committed := range map[string]bool{
|
||||
"limit": r.BrokeLimit,
|
||||
"attack": r.Attack,
|
||||
"blocked_by_block_rule": r.BlockedByBlockRule,
|
||||
"blocked_by_core_rule_set": r.BlockedByCoreRuleSet,
|
||||
"limit": r.BrokeLimit,
|
||||
"attack": r.Attack,
|
||||
"rule_blocked": r.RuleBlocked,
|
||||
"waf_blocked": r.WAFBlocked,
|
||||
} {
|
||||
if committed {
|
||||
m.offences.WithLabelValues(kind).Inc()
|
||||
@@ -441,10 +441,10 @@ func (m *Metrics) RuleMatched(id, action string) {
|
||||
m.ruleMatches.WithLabelValues(id, action).Inc()
|
||||
}
|
||||
|
||||
// CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
|
||||
// with SWW_WAF_MODE at mode.
|
||||
func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
|
||||
m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
||||
// with SWWAF_WAF_MODE at mode.
|
||||
func (m *Metrics) WAFMatched(mode string, id int) {
|
||||
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||
}
|
||||
|
||||
// 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
|
||||
// taken in while sww runs.
|
||||
// taken in while smallwebwaf runs.
|
||||
func (m *Metrics) StateFileEditTakenIn(name string) {
|
||||
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.
|
||||
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_reputation_queries_total",
|
||||
Name: "smallwebwaf_reputation_queries_total",
|
||||
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
||||
ConstLabels: source,
|
||||
}, func() float64 {
|
||||
@@ -519,7 +519,7 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in
|
||||
// that failed, which count tells.
|
||||
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_reputation_failures_total",
|
||||
Name: "smallwebwaf_reputation_failures_total",
|
||||
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
||||
"AbuseIPDB, that failed.",
|
||||
ConstLabels: source,
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
||||
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
||||
// Closed without an answer: refused, and no response.
|
||||
{Refused: true, Status: 0},
|
||||
// Answered 404 for a path under /_sww/: neither forwarded nor
|
||||
// Answered 404 for a path under /_smallwebwaf/: neither forwarded nor
|
||||
// refused.
|
||||
{Status: 404},
|
||||
} {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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
|
||||
// the client over a rate limit or a byte limit, and each client's history
|
||||
// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
|
||||
// since it was first seen. At most SWWAF_MAX_TRACKED_CLIENTS clients are
|
||||
// kept, in memory, and written to clients.json and read from it by the
|
||||
// state package.
|
||||
package ratelimit
|
||||
@@ -25,7 +25,7 @@ const (
|
||||
KindRequests = "requests"
|
||||
// KindBytes is a byte limit, on a client's bytes.
|
||||
KindBytes = "bytes"
|
||||
// KindRefusals is the error burst, on a client's requests sww
|
||||
// KindRefusals is the error burst, on a client's requests smallwebwaf
|
||||
// refused after a rule file match or for a missing or wrong token.
|
||||
KindRefusals = "refusals"
|
||||
)
|
||||
@@ -92,9 +92,9 @@ type History struct {
|
||||
ASName string `json:"as_name,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||
// Requests are all the client's requests: Forwarded those discriminator allowed,
|
||||
// Requests are all the client's requests: Forwarded those servewaf allowed,
|
||||
// Refused those it refused, and neither those for a path under
|
||||
// /_sww/.
|
||||
// /_smallwebwaf/.
|
||||
Requests int64 `json:"requests"`
|
||||
Forwarded int64 `json:"forwarded"`
|
||||
Refused int64 `json:"refused"`
|
||||
@@ -122,13 +122,12 @@ type Responses struct {
|
||||
type Offences struct {
|
||||
// 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
|
||||
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
|
||||
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
|
||||
// refused.
|
||||
Limit int64 `json:"limit"`
|
||||
Attack int64 `json:"attack"`
|
||||
BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
|
||||
BlockedByCoreRuleSet int64 `json:"blocked_by_core_rule_set"`
|
||||
// of a ban rule or a request for a trap path, RuleBlocked those a block
|
||||
// rule refused, and WAFBlocked those the Core Rule Set refused.
|
||||
Limit int64 `json:"limit"`
|
||||
Attack int64 `json:"attack"`
|
||||
RuleBlocked int64 `json:"rule_blocked"`
|
||||
WAFBlocked int64 `json:"waf_blocked"`
|
||||
// TokenRefused is what a clients.json written by an earlier version gives,
|
||||
// which must still load. Nothing adds to it.
|
||||
TokenRefused int64 `json:"token_refused,omitempty"`
|
||||
@@ -136,8 +135,8 @@ type Offences struct {
|
||||
|
||||
// Request is what a client's history keeps of one of its requests.
|
||||
type Request struct {
|
||||
// Forwarded is true for a request discriminator allowed, and Refused for one it
|
||||
// refused. Both are false for a request for a path under /_sww/.
|
||||
// Forwarded is true for a request servewaf allowed, and Refused for one it
|
||||
// refused. Both are false for a request for a path under /_smallwebwaf/.
|
||||
Forwarded bool
|
||||
Refused bool
|
||||
// Status is what the client was sent, 0 if nothing was.
|
||||
@@ -148,16 +147,16 @@ type Request struct {
|
||||
ResponseBytes int64
|
||||
// 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
|
||||
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
|
||||
// and BlockedByCoreRuleSet for one the Core Rule Set refused.
|
||||
BrokeLimit bool
|
||||
Attack bool
|
||||
BlockedByBlockRule bool
|
||||
BlockedByCoreRuleSet bool
|
||||
// asked for a trap path, RuleBlocked for one a block rule refused, and
|
||||
// WAFBlocked for one the Core Rule Set refused.
|
||||
BrokeLimit bool
|
||||
Attack bool
|
||||
RuleBlocked bool
|
||||
WAFBlocked bool
|
||||
}
|
||||
|
||||
// New returns a Limiter for limits, with no client counted yet, whose
|
||||
// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
|
||||
// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past
|
||||
// it, the least recently seen client is dropped, with its history, and
|
||||
// starts afresh if it comes back.
|
||||
func New(limits Limits, maxClients int) *Limiter {
|
||||
@@ -259,7 +258,7 @@ func (l *Limiter) CountBytes(
|
||||
return l.count(client, now, 0, bytes, percent)
|
||||
}
|
||||
|
||||
// CountRefusal counts a request from client at now that sww
|
||||
// CountRefusal counts a request from client at now that smallwebwaf
|
||||
// refused after a rule file or Core Rule Set match or for a missing or
|
||||
// wrong token, and reports whether the client's refusals in the minute
|
||||
// that ends at now, this one included, are more than threshold, which
|
||||
@@ -324,12 +323,12 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
||||
h.Offences.Attack++
|
||||
}
|
||||
|
||||
if r.BlockedByBlockRule {
|
||||
h.Offences.BlockedByBlockRule++
|
||||
if r.RuleBlocked {
|
||||
h.Offences.RuleBlocked++
|
||||
}
|
||||
|
||||
if r.BlockedByCoreRuleSet {
|
||||
h.Offences.BlockedByCoreRuleSet++
|
||||
if r.WAFBlocked {
|
||||
h.Offences.WAFBlocked++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
const limit = 3
|
||||
|
||||
// tableSize is the most clients the tests' tables hold, the default of
|
||||
// SWW_MAX_TRACKED_CLIENTS.
|
||||
// SWWAF_MAX_TRACKED_CLIENTS.
|
||||
const tableSize = 20000
|
||||
|
||||
// whole is the percentage of each limit a client gets when nothing lowers
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package remotelog sends the lines sww writes on stdout to the
|
||||
// remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section
|
||||
// Package remotelog sends the lines smallwebwaf writes on stdout to the
|
||||
// remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section
|
||||
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
||||
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
||||
// or unreachable endpoint never holds up a request or stdout.
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The forms of SWW_LOG_REMOTE_URL, by its scheme.
|
||||
// The forms of SWWAF_LOG_REMOTE_URL, by its scheme.
|
||||
const (
|
||||
SchemeUDP = "syslog+udp"
|
||||
SchemeTCP = "syslog+tcp"
|
||||
@@ -56,18 +56,18 @@ const (
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
// URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
||||
// URL is the endpoint (SWWAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
||||
// SchemeTLS, a host and a port.
|
||||
URL *url.URL
|
||||
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||
// must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||
// must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||
RootCAs *x509.CertPool
|
||||
// Buffer is the most lines held while they wait to be sent
|
||||
// (SWW_LOG_REMOTE_BUFFER).
|
||||
// (SWWAF_LOG_REMOTE_BUFFER).
|
||||
Buffer int
|
||||
// Facility is the number of the records' syslog facility
|
||||
// (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||
// (SWW_LOG_REMOTE_APP_NAME).
|
||||
// (SWWAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||
// (SWWAF_LOG_REMOTE_APP_NAME).
|
||||
Facility int
|
||||
AppName string
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
|
||||
}
|
||||
}
|
||||
|
||||
processLog.Warn("sending to SWW_LOG_REMOTE_URL failed",
|
||||
processLog.Warn("sending to SWWAF_LOG_REMOTE_URL failed",
|
||||
"error", err.Error(), "connecting_again_in", delay.String())
|
||||
|
||||
select {
|
||||
|
||||
@@ -44,7 +44,7 @@ import (
|
||||
const (
|
||||
// started is the time a record made as a test starts gives.
|
||||
started = "2000-01-01T00:00:00.000Z"
|
||||
appName = "fsn1app1/blog"
|
||||
appName = "fsn1app1/gitea"
|
||||
// local0 is the number of the default facility, and local0Info the
|
||||
// priority of its records.
|
||||
local0 = 16
|
||||
@@ -94,7 +94,7 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
|
||||
endpoint := listen(t)
|
||||
endpointParams := params(remotelog.SchemeTCP, endpoint.Addr())
|
||||
endpointParams.Facility = 19 // local3
|
||||
endpointParams.AppName = "blog"
|
||||
endpointParams.AppName = "gitea"
|
||||
sender, _, _ := run(t, endpointParams)
|
||||
|
||||
_, _ = sender.Write([]byte("first\nsecond\n"))
|
||||
@@ -103,8 +103,8 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
|
||||
wantCounts(t, sender, 2, 0, 0)
|
||||
|
||||
frames := bufio.NewReader(accept(t, endpoint))
|
||||
wantFrame(t, frames, record(t, "<158>", "blog", "first"))
|
||||
wantFrame(t, frames, record(t, "<158>", "blog", "second"))
|
||||
wantFrame(t, frames, record(t, "<158>", "gitea", "first"))
|
||||
wantFrame(t, frames, record(t, "<158>", "gitea", "second"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
|
||||
var fields map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(line), &fields)
|
||||
if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
|
||||
if err != nil || fields["msg"] != "sending to SWWAF_LOG_REMOTE_URL failed" {
|
||||
t.Fatalf("logged %q", line)
|
||||
}
|
||||
|
||||
@@ -612,7 +612,7 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "sww test CA"},
|
||||
Subject: pkix.Name{CommonName: "smallwebwaf test CA"},
|
||||
NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC),
|
||||
NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
IsCA: true,
|
||||
|
||||
@@ -58,17 +58,17 @@ type Checks struct {
|
||||
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
||||
type AbuseIPDBParams struct {
|
||||
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
||||
// the account's key (SWW_ABUSEIPDB_KEY).
|
||||
// the account's key (SWWAF_ABUSEIPDB_KEY).
|
||||
URL string
|
||||
Key string
|
||||
// MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE),
|
||||
// MinScore is the least score that is a hit (SWWAF_ABUSEIPDB_MIN_SCORE),
|
||||
// and DailyBudget the most checks made in a day, in UTC
|
||||
// (SWW_ABUSEIPDB_DAILY_BUDGET).
|
||||
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
|
||||
MinScore int64
|
||||
DailyBudget int
|
||||
// CacheTTL is how long a score is used after it was fetched
|
||||
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||
// (SWW_REPUTATION_TIMEOUT).
|
||||
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||
// (SWWAF_REPUTATION_TIMEOUT).
|
||||
CacheTTL time.Duration
|
||||
Timeout time.Duration
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
|
||||
@@ -397,7 +397,7 @@ func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) {
|
||||
"failures_total": "1",
|
||||
"daily_budget_remaining": "3",
|
||||
} {
|
||||
line := "\nsww_reputation_" + series +
|
||||
line := "\nsmallwebwaf_reputation_" + series +
|
||||
`{instance="app",source="abuseipdb"} ` + want + "\n"
|
||||
if !strings.Contains(scraped, line) {
|
||||
t.Errorf("metrics\n%s\nwant%s", scraped, line)
|
||||
@@ -581,7 +581,7 @@ func wantScore(
|
||||
|
||||
// hitFrom is checker's Hit for a request from address, offender or not.
|
||||
// Its client is address for an IPv4 address, and its /64 for an IPv6 one,
|
||||
// as sww counts clients.
|
||||
// as smallwebwaf counts clients.
|
||||
func hitFrom(
|
||||
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
|
||||
) (int64, bool) {
|
||||
|
||||
@@ -53,15 +53,15 @@ type Verdict struct {
|
||||
// DNSBLParams are what NewDNSBL needs.
|
||||
type DNSBLParams struct {
|
||||
// Zones are the DNSBL zones clients are asked about in
|
||||
// (SWW_DNSBL_ZONES).
|
||||
// (SWWAF_DNSBL_ZONES).
|
||||
Zones []string
|
||||
// Resolver is the resolver they are asked through
|
||||
// (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
||||
// (SWWAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
||||
// host's, as /etc/resolv.conf names it.
|
||||
Resolver netip.AddrPort
|
||||
// CacheTTL is how long a verdict is used after it was fetched
|
||||
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||
// (SWW_REPUTATION_TIMEOUT).
|
||||
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||
// (SWWAF_REPUTATION_TIMEOUT).
|
||||
CacheTTL time.Duration
|
||||
Timeout time.Duration
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
|
||||
}
|
||||
}
|
||||
|
||||
// Zones returns the zones, in the order SWW_DNSBL_ZONES names them.
|
||||
// Zones returns the zones, in the order SWWAF_DNSBL_ZONES names them.
|
||||
func (d *DNSBL) Zones() []string {
|
||||
return slices.Clone(d.params.Zones)
|
||||
}
|
||||
|
||||
// ListedBy returns the zones whose verdict on addr, a client's address,
|
||||
// lists it, in the order SWW_DNSBL_ZONES names them, each with its key
|
||||
// lists it, in the order SWWAF_DNSBL_ZONES names them, each with its key
|
||||
// masked, as config.MaskZoneKey masks it, since they go to the request
|
||||
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
||||
// passed since it was fetched. Each zone without one is asked about addr
|
||||
@@ -210,7 +210,7 @@ func (d *DNSBL) Snapshot() []Verdict {
|
||||
}
|
||||
|
||||
// Load keeps verdicts, read from reputation.json, in place of those it
|
||||
// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and,
|
||||
// keeps, but for those of a zone SWWAF_DNSBL_ZONES does not name, and,
|
||||
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
||||
// more is neither used nor written, as for any verdict.
|
||||
func (d *DNSBL) Load(verdicts []Verdict) {
|
||||
|
||||
@@ -40,8 +40,8 @@ const (
|
||||
// zone and otherZone are the DNSBL zones the tests name.
|
||||
zone = "dnsbl.example"
|
||||
otherZone = "other.example"
|
||||
// cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their
|
||||
// SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||
// cacheTTL is the tests' SWWAF_REPUTATION_CACHE_TTL, and timeout their
|
||||
// SWWAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||
// can have Go's resolver wait for one server, so that it is the
|
||||
// DNSBL's own timeout that ends a query, whatever that file says.
|
||||
cacheTTL = 24 * time.Hour
|
||||
@@ -311,7 +311,7 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) {
|
||||
"queries_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||
"failures_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||
} {
|
||||
line := "\nsww_reputation_" + series + " " + want + "\n"
|
||||
line := "\nsmallwebwaf_reputation_" + series + " " + want + "\n"
|
||||
if !strings.Contains(scraped.Body.String(), line) {
|
||||
t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line)
|
||||
}
|
||||
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
|
||||
dnsbl := reputation.NewDNSBL(p)
|
||||
|
||||
// The last verdict still in use, one fetched a TTL ago, and one of a
|
||||
// zone SWW_DNSBL_ZONES does not name.
|
||||
// zone SWWAF_DNSBL_ZONES does not name.
|
||||
inUse := reputation.Verdict{
|
||||
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
||||
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
||||
@@ -471,7 +471,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.
|
||||
}
|
||||
|
||||
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||
func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) {
|
||||
func TestQueriesGoToTheResolverSWWAFDNSBLResolverNames(t *testing.T) {
|
||||
resolver := &resolverStandIn{answers: map[string]answer{
|
||||
listedName: {addrs: []string{listing}},
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Package reputation fetches the lists the settings name by URL: the
|
||||
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
||||
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
|
||||
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
|
||||
// keeps the last good copy of
|
||||
// blocklists of SWWAF_BLOCKLIST_URLS, the file of AS:percent lines
|
||||
// SWWAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
||||
// engine SWWAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
||||
// each, whole, comment lines included, which is used while a fetch fails,
|
||||
// and when each was last tried. It also asks the DNSBL zones of
|
||||
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
||||
// today. The state package writes all of these to reputation.json and
|
||||
// reads them from it, so that a restart keeps them too.
|
||||
@@ -67,20 +66,18 @@ type List struct {
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
// BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and
|
||||
// BlocklistURLs are the blocklists (SWWAF_BLOCKLIST_URLS), and
|
||||
// ASNLimitPercentURL the file of AS:percent lines
|
||||
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||
BlocklistURLs []string
|
||||
ASNLimitPercentURL string
|
||||
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
|
||||
TagLists []config.TagList
|
||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
||||
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||
// (SWW_CROWDSEC_LAPI_KEY).
|
||||
// SWWAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||
// (SWWAF_CROWDSEC_LAPI_KEY).
|
||||
CrowdSecDecisionsURL string
|
||||
CrowdSecKey string
|
||||
// Refresh is how long after a list was last fetched or tried it is
|
||||
// fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
||||
// fetched again (SWWAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
||||
// list, which is fetched again crowdSecRefresh after.
|
||||
Refresh time.Duration
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
@@ -115,11 +112,11 @@ type list struct {
|
||||
failures int
|
||||
}
|
||||
|
||||
// entries are what the lines of a copy say: for a blocklist or a tag list, the
|
||||
// netblocks it names, with the lengths among them, for the file of AS:percent
|
||||
// lines, the percentage it gives each AS number, and for the CrowdSec decision
|
||||
// list, the decision on each netblock that ends last, with the lengths among
|
||||
// them.
|
||||
// entries are what the lines of a copy say: for a blocklist, the netblocks
|
||||
// it names, with the lengths among them, for the file of AS:percent lines,
|
||||
// the percentage it gives each AS number, and for the CrowdSec decision
|
||||
// list, the decision on each netblock that ends last, with the lengths
|
||||
// among them.
|
||||
type entries struct {
|
||||
netblocks map[netip.Prefix]bool
|
||||
lengths []int
|
||||
@@ -155,18 +152,14 @@ func New(params Params) *Lists {
|
||||
}
|
||||
|
||||
// URLs returns the URL of every list: the blocklists' in the order
|
||||
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
|
||||
// tag lists', then the CrowdSec decision list's.
|
||||
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL, then
|
||||
// the CrowdSec decision list's.
|
||||
func (l *Lists) URLs() []string {
|
||||
urls := slices.Clone(l.params.BlocklistURLs)
|
||||
if l.params.ASNLimitPercentURL != "" {
|
||||
urls = append(urls, l.params.ASNLimitPercentURL)
|
||||
}
|
||||
|
||||
for _, list := range l.params.TagLists {
|
||||
urls = append(urls, list.URL)
|
||||
}
|
||||
|
||||
if l.params.CrowdSecDecisionsURL != "" {
|
||||
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
||||
}
|
||||
@@ -175,7 +168,7 @@ func (l *Lists) URLs() []string {
|
||||
}
|
||||
|
||||
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
||||
// the order SWW_BLOCKLIST_URLS names them.
|
||||
// the order SWWAF_BLOCKLIST_URLS names them.
|
||||
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -191,22 +184,6 @@ func (l *Lists) ListedBy(addr netip.Addr) []string {
|
||||
return listedBy
|
||||
}
|
||||
|
||||
// TagsOf returns the names of the tag lists whose copy holds addr.
|
||||
func (l *Lists) TagsOf(addr netip.Addr) []string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var names []string
|
||||
|
||||
for _, list := range l.params.TagLists {
|
||||
if l.lists[list.URL].entries.contain(addr) {
|
||||
names = append(names, list.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// ASNLimitPercent returns the percentage the copy of the file of
|
||||
// AS:percent lines gives asn, and whether it lists asn.
|
||||
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
||||
@@ -394,7 +371,7 @@ func (l *Lists) due(listURL string) time.Time {
|
||||
// fetch fetches the list at listURL, and notes the try. A good copy takes
|
||||
// the place of the one held. A failure leaves that in use, and is counted,
|
||||
// logged and raised as a source_failure alert. A fetch cut off as ctx
|
||||
// ends, as sww stops, is no failure, but is still noted as a try,
|
||||
// ends, as smallwebwaf stops, is no failure, but is still noted as a try,
|
||||
// so that a restart waits for it: the server may have had its request.
|
||||
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
||||
lines, err := l.get(ctx, listURL)
|
||||
@@ -504,10 +481,10 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
||||
}
|
||||
|
||||
// parse reads the lines of the list at listURL, fetched at fetched: those
|
||||
// of a blocklist or a tag list, of the file of AS:percent lines, or of the
|
||||
// CrowdSec decision list. In all but the decision list, anything after a ; or
|
||||
// a # on a line is left out, and so is a line left blank. Any other line that
|
||||
// does not read is an error naming it by its number.
|
||||
// of a blocklist, of the file of AS:percent lines, or of the CrowdSec
|
||||
// decision list. In the first two, anything after a ; or a # on a line is
|
||||
// left out, and so is a line left blank. Any other line that does not read
|
||||
// is an error naming it by its number.
|
||||
func (l *Lists) parse(
|
||||
listURL string, lines []string, fetched time.Time,
|
||||
) (entries, error) {
|
||||
@@ -521,8 +498,8 @@ func (l *Lists) parse(
|
||||
}
|
||||
}
|
||||
|
||||
// parseNetblocks reads the lines of a blocklist or a tag list, each an address
|
||||
// or a netblock as the settings take them.
|
||||
// parseNetblocks reads a blocklist's lines, each an address or a netblock
|
||||
// as the settings take them.
|
||||
func parseNetblocks(lines []string) (entries, error) {
|
||||
found := entries{netblocks: map[netip.Prefix]bool{}}
|
||||
|
||||
@@ -574,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
|
||||
}
|
||||
|
||||
// parsePercents reads the lines of the file of AS:percent lines, each an
|
||||
// AS number, : and a percentage, such as AS64496:50. An
|
||||
// AS number, : and a percentage, as SWWAF_ASN_LIMIT_PERCENT takes them. An
|
||||
// AS number listed more than once gets the lowest of its percentages.
|
||||
func parsePercents(lines []string) (entries, error) {
|
||||
found := entries{percents: map[string]int64{}}
|
||||
|
||||
@@ -34,8 +34,8 @@ const (
|
||||
dropURL = "https://lists.example/drop.txt"
|
||||
torURL = "https://lists.example/tor.txt"
|
||||
asnURL = "https://lists.example/asn.txt"
|
||||
// refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their
|
||||
// SWW_ALERT_COOLDOWN, longer than it.
|
||||
// refresh is the tests' SWWAF_BLOCKLIST_REFRESH, and cooldown their
|
||||
// SWWAF_ALERT_COOLDOWN, longer than it.
|
||||
refresh = 24 * time.Hour
|
||||
cooldown = 48 * time.Hour
|
||||
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
||||
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
|
||||
}}
|
||||
lists := start(t, servers, params(torURL, dropURL))
|
||||
|
||||
// In the order SWW_BLOCKLIST_URLS names them.
|
||||
// In the order SWWAF_BLOCKLIST_URLS names them.
|
||||
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
||||
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package requestlog writes the lines sww prints on stdout: one
|
||||
// JSON object per request, two from discriminator and a short one from rproxy,
|
||||
// marked "type":"request", and the process's own messages as JSON lines marked
|
||||
// Package requestlog writes the lines smallwebwaf prints on stdout: one
|
||||
// JSON object per request, two from servewaf and a short one from rproxy, marked
|
||||
// "type":"request", and the process's own messages as JSON lines marked
|
||||
// "type":"process".
|
||||
package requestlog
|
||||
|
||||
@@ -14,50 +14,37 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
// The action a request line names: what discriminator did with the request.
|
||||
// The action a request line names: what servewaf did with the request.
|
||||
const (
|
||||
// ActionForward is a request discriminator allowed, which rproxy passes to
|
||||
// the app.
|
||||
// ActionForward is a request servewaf allowed, which rproxy passes to the
|
||||
// app.
|
||||
ActionForward = "forward"
|
||||
|
||||
// ActionRequestHeaderTooLarge is a request over the header limits.
|
||||
ActionRequestHeaderTooLarge = "request_header_too_large"
|
||||
|
||||
// ActionClientIsRateLimited is a request refused because it took its
|
||||
// client over a rate limit, which bans the client.
|
||||
ActionClientIsRateLimited = "client_is_rate_limited"
|
||||
|
||||
// ActionTemporarilyBanned is a request refused because a ban that runs
|
||||
// out covers its client, or because it matched a ban rule, asked for a
|
||||
// trap path or the CrowdSec decision list lists its client, and so made
|
||||
// such a ban.
|
||||
ActionTemporarilyBanned = "temporarily_banned"
|
||||
|
||||
// ActionPermanentlyBanned is a request refused because a ban that never
|
||||
// runs out covers its client, or because the request made such a ban or
|
||||
// made its client's ban permanent.
|
||||
ActionPermanentlyBanned = "permanently_banned"
|
||||
|
||||
// ActionBlockedByBlockRule is a request refused because it matched a
|
||||
// block rule.
|
||||
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
||||
|
||||
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
|
||||
// Set scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
||||
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
|
||||
|
||||
// ActionClientDenied is a request refused because its client is in
|
||||
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
||||
// ActionTooLarge is a request over the header limits.
|
||||
ActionTooLarge = "too_large"
|
||||
// ActionRateLimited is a request refused because it took its client
|
||||
// over a rate limit, which bans the client.
|
||||
ActionRateLimited = "rate_limited"
|
||||
// ActionBanned is a request refused because a ban covers its client,
|
||||
// or because it matched a ban rule, asked for a trap path or the
|
||||
// CrowdSec decision list lists its client, each of which bans the
|
||||
// client.
|
||||
ActionBanned = "banned"
|
||||
// ActionRuleBlocked is a request refused because it matched a block
|
||||
// rule.
|
||||
ActionRuleBlocked = "rule_blocked"
|
||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD.
|
||||
ActionWAFBlocked = "waf_blocked"
|
||||
// ActionDenied is a request refused because its client is in
|
||||
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny,
|
||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
|
||||
ActionClientDenied = "client_denied"
|
||||
|
||||
// ActionCountryDenied is a request refused because its client's country
|
||||
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||
// SWWAF_REPUTATION_ACTION is deny.
|
||||
ActionDenied = "denied"
|
||||
// ActionCountryDenied is a request refused for its client's country.
|
||||
ActionCountryDenied = "country_denied"
|
||||
// ActionOwnEndpoint is a request discriminator answered at one of its own
|
||||
// endpoints, or a question about a path under /_sww/.
|
||||
ActionOwnEndpoint = "own_endpoint"
|
||||
// ActionAdmin is a request servewaf answered at one of its own endpoints,
|
||||
// or a question about a path under /_smallwebwaf/.
|
||||
ActionAdmin = "admin"
|
||||
)
|
||||
|
||||
// OffenceLimit is the offence a request line names for a request that
|
||||
@@ -65,18 +52,18 @@ const (
|
||||
// limit.
|
||||
const OffenceLimit = "limit"
|
||||
|
||||
// LimitHitMinuteRefusals is the limit_hit a request line names for a request
|
||||
// LimitHitErrorBurst is the limit_hit a request line names for a request
|
||||
// that broke the error burst.
|
||||
const LimitHitMinuteRefusals = "minute_refusals"
|
||||
const LimitHitErrorBurst = "error_burst"
|
||||
|
||||
// The discriminator_line a request line names, which says which of
|
||||
// discriminator's two lines about a question it is.
|
||||
// The servewaf_line a request line names, which says which of servewaf's two
|
||||
// lines about a question it is.
|
||||
const (
|
||||
// DiscriminatorLineDecision is discriminator's line at its decision.
|
||||
DiscriminatorLineDecision = "decision"
|
||||
// DiscriminatorLineReport is discriminator's line when rproxy's report
|
||||
// comes, with the decision's fields and the report's together.
|
||||
DiscriminatorLineReport = "report"
|
||||
// ServeWAFLineDecision is servewaf's line at its decision.
|
||||
ServeWAFLineDecision = "decision"
|
||||
// ServeWAFLineReport is servewaf's line when rproxy's report comes, with
|
||||
// the decision's fields and the report's together.
|
||||
ServeWAFLineReport = "report"
|
||||
)
|
||||
|
||||
// timeLayout is RFC 3339 with milliseconds.
|
||||
@@ -89,9 +76,9 @@ const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||
type Line struct {
|
||||
Type string `json:"type"`
|
||||
// DiscriminatorLine is DiscriminatorLineDecision or DiscriminatorLineReport
|
||||
// on discriminator's two lines about a question.
|
||||
DiscriminatorLine string `json:"discriminator_line,omitempty"`
|
||||
// ServeWAFLine is ServeWAFLineDecision or ServeWAFLineReport on servewaf's
|
||||
// two lines about a question.
|
||||
ServeWAFLine string `json:"servewaf_line,omitempty"`
|
||||
|
||||
// The standard web log fields. Scheme is how the client reached rproxy, or
|
||||
// the trusted proxy in front of it.
|
||||
@@ -111,7 +98,7 @@ type Line struct {
|
||||
UserAgent string `json:"user_agent"`
|
||||
|
||||
// Request detail. RequestID is the id rproxy made for the request, or for a
|
||||
// request to discriminator's own endpoints a new one. ForwardedFor is the
|
||||
// request to servewaf's own endpoints a new one. ForwardedFor is the
|
||||
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
||||
// client is counted as. ASN, ASName and Country are the client's AS
|
||||
// number, AS name and country, as looked up.
|
||||
@@ -125,45 +112,39 @@ type Line struct {
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
// ContentLength is the length of its body the request announced.
|
||||
ContentLength int64 `json:"content_length,omitempty"`
|
||||
// RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that
|
||||
// RequestHeaders are the headers SWWAF_LOG_REQUEST_HEADERS names that
|
||||
// the request carried, by name in lower case.
|
||||
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||
HasCookie bool `json:"has_cookie,omitempty"`
|
||||
|
||||
// Response detail, of discriminator's own answer. Aborted is true when
|
||||
// rproxy, or the admin's tool, went away before the answer.
|
||||
// Response detail, of servewaf's own answer. Aborted is true when rproxy, or
|
||||
// the admin's tool, went away before the answer.
|
||||
ResponseContentType string `json:"response_content_type,omitempty"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
|
||||
// The decision.
|
||||
Action string `json:"action"`
|
||||
// WouldAction is, in observe mode, the action enforce mode would have
|
||||
// taken with a request it would have refused: ActionClientDenied,
|
||||
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
|
||||
// ActionClientIsRateLimited, ActionBlockedByBlockRule,
|
||||
// ActionBlockedByCoreRuleSet, or, in discriminator,
|
||||
// ActionRequestHeaderTooLarge for the header limits.
|
||||
// taken with a request it would have refused: ActionDenied,
|
||||
// ActionBanned, ActionCountryDenied, ActionRateLimited,
|
||||
// ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge
|
||||
// for the header limits.
|
||||
WouldAction string `json:"would_action,omitempty"`
|
||||
// Heuristic is the name of the heuristic that refused the request, in
|
||||
// observe mode too, and HeuristicFigures are the figures it acted on, by
|
||||
// name.
|
||||
// Heuristic is the name of the heuristic that refused the request or
|
||||
// banned its client, in observe mode too, and HeuristicFigures are the
|
||||
// figures it acted on, by name.
|
||||
Heuristic string `json:"heuristic,omitempty"`
|
||||
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
|
||||
// AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
|
||||
// the heuristic that banned the client after discriminator answered the
|
||||
// request.
|
||||
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
|
||||
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
|
||||
// RateLimitPercent and RateLimitPercentSetting are, for a request the
|
||||
// rate limits counted whose client a biased threshold gives a percentage
|
||||
// of the rate limits below 100, that percentage and the setting that gave
|
||||
// it. BytesLimitPercent and BytesLimitPercentSetting are the same for the
|
||||
// byte limits.
|
||||
RateLimitPercent *int64 `json:"rate_limit_percent,omitempty"`
|
||||
RateLimitPercentSetting string `json:"rate_limit_percent_setting,omitempty"`
|
||||
BytesLimitPercent *int64 `json:"bytes_limit_percent,omitempty"`
|
||||
BytesLimitPercentSetting string `json:"bytes_limit_percent_setting,omitempty"`
|
||||
// LimitPercent and LimitPercentSetting are, for a request the rate
|
||||
// limits counted whose client a biased threshold gives a percentage of
|
||||
// the rate limits below 100, that percentage and the setting that gave
|
||||
// it. BytesPercent and BytesPercentSetting are the same for the byte
|
||||
// limits.
|
||||
LimitPercent *int64 `json:"limit_percent,omitempty"`
|
||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||
BytesPercent *int64 `json:"bytes_percent,omitempty"`
|
||||
BytesPercentSetting string `json:"bytes_percent_setting,omitempty"`
|
||||
// Counts are, for a request the rate limits counted, the client's
|
||||
// requests as they counted them with this one, and its bytes as the
|
||||
// byte limits counted them, with this request's once it has ended if
|
||||
@@ -171,42 +152,36 @@ type Line struct {
|
||||
Counts ratelimit.Counts `json:"counts,omitzero"`
|
||||
// RuleIDs are the ids of the rule file rules the request matched.
|
||||
RuleIDs []string `json:"rule_ids,omitempty"`
|
||||
// CoreRuleSetRuleIDs are the ids of the Core Rule Set's rules the request
|
||||
// matched, and CoreRuleSetScore its anomaly score, nil for a request the
|
||||
// Core Rule Set did not inspect.
|
||||
CoreRuleSetRuleIDs []int `json:"core_rule_set_rule_ids,omitempty"`
|
||||
CoreRuleSetScore *int `json:"core_rule_set_score,omitempty"`
|
||||
// WAFRuleIDs are the ids of the Core Rule Set's rules the request
|
||||
// matched, and WAFScore its anomaly score, nil for a request the Core
|
||||
// Rule Set did not inspect.
|
||||
WAFRuleIDs []int `json:"waf_rule_ids,omitempty"`
|
||||
WAFScore *int `json:"waf_score,omitempty"`
|
||||
// LimitHit is the window whose limit the request went over, named as
|
||||
// Counts names its count: minute, hour or day for a rate limit, and
|
||||
// minute_bytes, hour_bytes or day_bytes for a byte limit; or
|
||||
// LimitHitMinuteRefusals for the error burst.
|
||||
// LimitHitErrorBurst for the error burst.
|
||||
LimitHit string `json:"limit_hit,omitempty"`
|
||||
// Reputation are the URLs of the blocklists that list the client, then
|
||||
// that of the CrowdSec decision list when it does, then the DNSBL zones
|
||||
// whose verdict lists it, their keys masked, then abuseipdb when its
|
||||
// score is a hit.
|
||||
Reputation []string `json:"reputation,omitempty"`
|
||||
// Tags are the request's tags, sorted.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
// RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the
|
||||
// request got no tags from routewatch: no_answer_yet, request_failed or
|
||||
// no_route.
|
||||
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
|
||||
// Offence is the offence the request was held as, OffenceLimit.
|
||||
Offence string `json:"offence,omitempty"`
|
||||
// BanExpires is when the ban the request made, or was refused under,
|
||||
// ends: a time, or "permanent".
|
||||
BanExpires string `json:"ban_expires,omitempty"`
|
||||
|
||||
// The timings. DurationChecksMS is the time until the checks were done, and
|
||||
// DurationCoreRuleSetMS the part of it the Core Rule Set took.
|
||||
// DurationUpstreamTotalMS is what the report of an allowed request gives,
|
||||
// The timings, in milliseconds. DurationChecks is the time until the
|
||||
// checks were done, and DurationWAF the part of it the Core Rule Set
|
||||
// took. DurationUpstreamTotal is what the report of an allowed request gives,
|
||||
// from the hand-over to the app until the end of its answer. Each but
|
||||
// DurationTotalMS is nil for a request that did not get that far.
|
||||
DurationTotalMS float64 `json:"duration_total_ms"`
|
||||
DurationChecksMS *float64 `json:"duration_checks_ms,omitempty"`
|
||||
DurationCoreRuleSetMS *float64 `json:"duration_core_rule_set_ms,omitempty"`
|
||||
DurationUpstreamTotalMS *float64 `json:"duration_upstream_total_ms,omitempty"`
|
||||
// DurationTotal is nil for a request that did not get that far.
|
||||
DurationTotal float64 `json:"duration_total"`
|
||||
DurationChecks *float64 `json:"duration_checks,omitempty"`
|
||||
DurationWAF *float64 `json:"duration_waf,omitempty"`
|
||||
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
|
||||
}
|
||||
|
||||
// RProxyLine is rproxy's short line in the request log about one request. The
|
||||
@@ -226,12 +201,12 @@ type RProxyLine struct {
|
||||
Status int `json:"status"`
|
||||
RequestBytes int64 `json:"request_bytes"`
|
||||
ResponseBytes int64 `json:"response_bytes"`
|
||||
// DurationRequestMS runs from the request's headers being read until the app
|
||||
// has been sent the whole request, or the answer has started if that is
|
||||
// sooner. DurationResponseMS runs from then until the answer ends.
|
||||
DurationRequestMS float64 `json:"duration_request_ms"`
|
||||
DurationResponseMS float64 `json:"duration_response_ms"`
|
||||
// Disposition is the valid disposition discriminator answered or, while
|
||||
// DurationRequest runs in milliseconds from the request's headers being read
|
||||
// until the app has been sent the whole request, or the answer has started
|
||||
// if that is sooner. DurationResponse runs from then until the answer ends.
|
||||
DurationRequest float64 `json:"duration_request"`
|
||||
DurationResponse float64 `json:"duration_response"`
|
||||
// Disposition is the valid disposition servewaf answered or, while
|
||||
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
|
||||
// when neither gave one.
|
||||
Disposition string `json:"disposition,omitempty"`
|
||||
@@ -275,37 +250,25 @@ func FormatTime(t time.Time) string {
|
||||
|
||||
// Milliseconds is d in milliseconds, to the microsecond.
|
||||
func Milliseconds(d time.Duration) float64 {
|
||||
return float64(
|
||||
d.Microseconds(),
|
||||
) / float64(
|
||||
time.Millisecond/time.Microsecond,
|
||||
)
|
||||
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
|
||||
}
|
||||
|
||||
// NewProcessLogger returns the logger for the process's own messages:
|
||||
// JSON lines on w, marked "type":"process", with the time in the same form
|
||||
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
||||
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
||||
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
|
||||
// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe;
|
||||
// the request lines Write writes are never held back.
|
||||
func NewProcessLogger(
|
||||
w io.Writer,
|
||||
instanceName string,
|
||||
level slog.Level,
|
||||
) *slog.Logger {
|
||||
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
||||
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
||||
return slog.String(
|
||||
slog.TimeKey,
|
||||
FormatTime(attr.Value.Time()),
|
||||
)
|
||||
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
|
||||
}
|
||||
|
||||
return attr
|
||||
},
|
||||
})
|
||||
|
||||
return slog.New(handler).
|
||||
With("type", "process", "instance", instanceName)
|
||||
return slog.New(handler).With("type", "process", "instance", instanceName)
|
||||
}
|
||||
@@ -18,11 +18,11 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
|
||||
err := requestlog.Write(&out, &requestlog.Line{
|
||||
Time: requestlog.FormatTime(time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)),
|
||||
ClientIP: "203.0.113.9",
|
||||
Status: 200,
|
||||
Action: requestlog.ActionForward,
|
||||
DurationTotalMS: requestlog.Milliseconds(1500 * time.Microsecond),
|
||||
Time: requestlog.FormatTime(time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)),
|
||||
ClientIP: "203.0.113.9",
|
||||
Status: 200,
|
||||
Action: requestlog.ActionForward,
|
||||
DurationTotal: requestlog.Milliseconds(1500 * time.Microsecond),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
@@ -43,7 +43,7 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
||||
want := map[string]any{
|
||||
"type": "request", "time": "2026-10-03T12:00:00.000Z",
|
||||
"client_ip": "203.0.113.9", "status": 200.0, "action": "forward",
|
||||
"duration_total_ms": 1.5,
|
||||
"duration_total": 1.5,
|
||||
}
|
||||
for name, value := range want {
|
||||
if fields[name] != value {
|
||||
@@ -55,9 +55,9 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
||||
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||
"has_authorization", "has_cookie", "websocket", "response_content_type",
|
||||
"upstream_status", "cache_control", "location", "aborted", "counts",
|
||||
"limit_hit", "offence", "ban_expires", "duration_checks_ms",
|
||||
"limit_hit", "offence", "ban_expires", "duration_checks",
|
||||
"duration_upstream_connect", "duration_upstream_first_byte",
|
||||
"duration_upstream_total_ms",
|
||||
"duration_upstream_total",
|
||||
}
|
||||
for _, name := range unset {
|
||||
_, present := fields[name]
|
||||
@@ -82,7 +82,7 @@ func TestWriteRProxyLineWritesOneJSONLineWithOnlyItsFields(t *testing.T) {
|
||||
|
||||
const want = `{"type":"request","time":"","request_id":"an-id","client_ip":"",` +
|
||||
`"host":"","method":"","path":"","query":"","status":0,"request_bytes":0,` +
|
||||
`"response_bytes":0,"duration_request_ms":0,"duration_response_ms":0,` +
|
||||
`"response_bytes":0,"duration_request":0,"duration_response":0,` +
|
||||
`"disposition":"allow"}` + "\n"
|
||||
if out.String() != want {
|
||||
t.Errorf("wrote %s, want %s", out.String(), want)
|
||||
@@ -94,7 +94,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
requestlog.NewProcessLogger(&out, "fsn1app1/blog", slog.LevelInfo).Info("starting",
|
||||
requestlog.NewProcessLogger(&out, "fsn1app1/gitea", slog.LevelInfo).Info("starting",
|
||||
"version", "v1")
|
||||
|
||||
var fields map[string]any
|
||||
@@ -104,7 +104,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
||||
t.Fatalf("decode %q: %v", out.String(), err)
|
||||
}
|
||||
|
||||
if fields["type"] != "process" || fields["instance"] != "fsn1app1/blog" ||
|
||||
if fields["type"] != "process" || fields["instance"] != "fsn1app1/gitea" ||
|
||||
fields["msg"] != "starting" || fields["level"] != "INFO" ||
|
||||
fields["version"] != "v1" {
|
||||
t.Errorf("process line %v", fields)
|
||||
@@ -133,7 +133,7 @@ func TestProcessLoggerWritesTheMessagesAtItsLevelOrMoreSevere(t *testing.T) {
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
processLog := requestlog.NewProcessLogger(&out, "fsn1app1/blog", level)
|
||||
processLog := requestlog.NewProcessLogger(&out, "fsn1app1/gitea", level)
|
||||
for _, at := range levels {
|
||||
processLog.Log(t.Context(), at, "message")
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
// Package routewatch asks a routewatch instance about client addresses, in the
|
||||
// background, and keeps its answers in memory. A routewatch instance knows, for
|
||||
// an address, the netblock it is announced in and the AS that originates it.
|
||||
package routewatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||
)
|
||||
|
||||
const (
|
||||
// answerKept is how long an answer is used after it came.
|
||||
answerKept = time.Hour
|
||||
// failureKept is how long routewatch is not asked again after a query
|
||||
// fails, so that a dead routewatch is not asked on every request.
|
||||
failureKept = time.Minute
|
||||
// queryTimeout is how long one query may take. routewatch answers only
|
||||
// after a reverse DNS lookup it allows 2 seconds, so this is longer.
|
||||
queryTimeout = 5 * time.Second
|
||||
// maxAnswers is how many answers are kept. Past it, the one that came
|
||||
// longest ago is dropped.
|
||||
maxAnswers = 100000
|
||||
// maxQueries is how many queries may be under way at once. Past it, a
|
||||
// client is not asked about until its next request, so that a swarm of
|
||||
// new addresses cannot fill the memory.
|
||||
maxQueries = 100
|
||||
// maxAnswerBytes is the most of an answer that is read.
|
||||
maxAnswerBytes = 64 << 10
|
||||
)
|
||||
|
||||
// Missing is why Route gives no route. The request log's field
|
||||
// routewatch_tags_missing gives these values.
|
||||
type Missing string
|
||||
|
||||
const (
|
||||
// NoAnswerYet is a client routewatch has not answered about yet.
|
||||
NoAnswerYet Missing = "no_answer_yet"
|
||||
// RequestFailed is no answer because a recent query failed.
|
||||
RequestFailed Missing = "request_failed"
|
||||
// NoRoute is routewatch knowing no live route for the address.
|
||||
NoRoute Missing = "no_route"
|
||||
)
|
||||
|
||||
var (
|
||||
errStatus = errors.New("routewatch answered")
|
||||
errNoResult = errors.New("the answer holds no result")
|
||||
errNotNetblock = errors.New("the answer's netblock is not a netblock")
|
||||
)
|
||||
|
||||
// Route is what routewatch knows of an address: the netblock it is announced
|
||||
// in, and the AS that originates it.
|
||||
type Route struct {
|
||||
Netblock netip.Prefix
|
||||
ASN uint32
|
||||
}
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
// URL is where routewatch is (SWW_ROUTEWATCH_URL).
|
||||
URL string
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
Now func() time.Time
|
||||
// ProcessLog receives the query that fails, and why.
|
||||
ProcessLog *slog.Logger
|
||||
}
|
||||
|
||||
// Client asks a routewatch instance about clients, in the background, and keeps
|
||||
// its answers. It is safe for concurrent use.
|
||||
type Client struct {
|
||||
params Params
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
// answers are by client. Each is added as it comes and never moved up,
|
||||
// so that the one that came longest ago is the first dropped.
|
||||
answers *simplelru.LRU[netip.Prefix, answer]
|
||||
// asking are the clients whose query is under way.
|
||||
asking map[netip.Prefix]bool
|
||||
// retryAt is when routewatch may be asked again after a query failed.
|
||||
retryAt time.Time
|
||||
}
|
||||
|
||||
// answer is one kept answer: the route, or why there is none, and when it came.
|
||||
type answer struct {
|
||||
route Route
|
||||
missing Missing
|
||||
came time.Time
|
||||
}
|
||||
|
||||
// New returns a Client with no answer yet.
|
||||
func New(params Params) *Client {
|
||||
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
|
||||
return &Client{
|
||||
params: params,
|
||||
httpClient: &http.Client{},
|
||||
answers: answers,
|
||||
asking: map[netip.Prefix]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// Route returns what routewatch answered about client, an IPv4 address or an
|
||||
// IPv6 group, or why it has no route. It never waits for a query. A client
|
||||
// without an answer is asked about in the background, by addr, the address its
|
||||
// request came from, unless its query is under way, a query failed less than
|
||||
// failureKept ago, or maxQueries are under way. ctx is the context of the
|
||||
// client's request, and a query goes on after the request ends.
|
||||
func (c *Client) Route(
|
||||
ctx context.Context, client netip.Prefix, addr netip.Addr,
|
||||
) (Route, Missing) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := c.params.Now()
|
||||
|
||||
kept, found := c.answers.Peek(client)
|
||||
if found && now.Sub(kept.came) < answerKept {
|
||||
return kept.route, kept.missing
|
||||
}
|
||||
|
||||
if now.Before(c.retryAt) {
|
||||
return Route{}, RequestFailed
|
||||
}
|
||||
|
||||
if !c.asking[client] && len(c.asking) < maxQueries {
|
||||
c.asking[client] = true
|
||||
|
||||
go c.ask(context.WithoutCancel(ctx), client, addr)
|
||||
}
|
||||
|
||||
return Route{}, NoAnswerYet
|
||||
}
|
||||
|
||||
// ask asks routewatch about addr, keeps the answer as client's, and notes the
|
||||
// query as no longer under way. A query that fails gives no answer, and
|
||||
// routewatch is not asked again for failureKept. Only the first failure of
|
||||
// those under way is logged.
|
||||
func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
|
||||
route, missing, err := c.query(ctx, addr)
|
||||
now := c.params.Now()
|
||||
|
||||
c.mu.Lock()
|
||||
|
||||
delete(c.asking, client)
|
||||
|
||||
firstFailure := err != nil && !now.Before(c.retryAt)
|
||||
|
||||
if err == nil {
|
||||
c.answers.Add(client, answer{route: route, missing: missing, came: now})
|
||||
} else {
|
||||
c.retryAt = now.Add(failureKept)
|
||||
}
|
||||
|
||||
c.mu.Unlock()
|
||||
|
||||
if firstFailure {
|
||||
c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// query asks routewatch's IP info API about addr. An answer other than 200, one
|
||||
// that cannot be read, and none within queryTimeout, fail. A result with no
|
||||
// netblock is an address without a live route.
|
||||
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String())
|
||||
if err != nil {
|
||||
return Route{}, "", fmt.Errorf("make the URL: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
||||
if err != nil {
|
||||
return Route{}, "", fmt.Errorf("make the request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Do's error names the URL, which holds the client's address, which
|
||||
// is not to be logged: only what went wrong is kept.
|
||||
return Route{}, "", fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = res.Body.Close()
|
||||
}()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return Route{}, "", fmt.Errorf("%w %s", errStatus, res.Status)
|
||||
}
|
||||
|
||||
return readAnswer(io.LimitReader(res.Body, maxAnswerBytes))
|
||||
}
|
||||
|
||||
// readAnswer reads routewatch's answer about one address.
|
||||
func readAnswer(body io.Reader) (Route, Missing, error) {
|
||||
var decoded struct {
|
||||
Data struct {
|
||||
Results []struct {
|
||||
Netblock string `json:"netblock"`
|
||||
ASN uint32 `json:"asn"`
|
||||
} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
err := json.NewDecoder(body).Decode(&decoded)
|
||||
if err != nil {
|
||||
return Route{}, "", fmt.Errorf("read the answer: %w", err)
|
||||
}
|
||||
|
||||
if len(decoded.Data.Results) == 0 {
|
||||
return Route{}, "", errNoResult
|
||||
}
|
||||
|
||||
result := decoded.Data.Results[0]
|
||||
if result.Netblock == "" {
|
||||
return Route{}, NoRoute, nil
|
||||
}
|
||||
|
||||
netblock, err := netip.ParsePrefix(result.Netblock)
|
||||
if err != nil {
|
||||
return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
|
||||
}
|
||||
|
||||
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package routewatch_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
||||
)
|
||||
|
||||
const (
|
||||
clientAddr = "192.0.2.1"
|
||||
routedAnswer = `{"status":"ok","data":{"results":[` +
|
||||
`{"ip":"192.0.2.1","netblock":"192.0.2.0/24","asn":64496}]}}`
|
||||
noRouteAnswer = `{"status":"ok","data":{"results":[{"ip":"192.0.2.1"}]}}`
|
||||
// slowAnswer is longer than routewatch allows a reverse DNS lookup.
|
||||
slowAnswer = 2500 * time.Millisecond
|
||||
// waitLimit is longer than the Client allows a query.
|
||||
waitLimit = 10 * time.Second
|
||||
)
|
||||
|
||||
// clock is a time the tests move by hand.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *clock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *clock) advance(by time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.now = c.now.Add(by)
|
||||
}
|
||||
|
||||
// fixture is a Client asking a stand-in for routewatch. queries counts what the
|
||||
// stand-in was asked, paths holds the last path it was asked for, and log holds
|
||||
// the process log.
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
client *routewatch.Client
|
||||
clk *clock
|
||||
queries *atomic.Int32
|
||||
path *atomic.Value
|
||||
log *syncBuffer
|
||||
}
|
||||
|
||||
// syncBuffer is a buffer the background queries can log to while a test reads it.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, standIn http.HandlerFunc) *fixture {
|
||||
t.Helper()
|
||||
|
||||
f := &fixture{
|
||||
t: t,
|
||||
clk: &clock{now: time.Date(2026, 11, 9, 0, 0, 0, 0, time.UTC)},
|
||||
queries: &atomic.Int32{},
|
||||
path: &atomic.Value{},
|
||||
log: &syncBuffer{},
|
||||
}
|
||||
|
||||
f.server = httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
f.queries.Add(1)
|
||||
f.path.Store(r.URL.Path)
|
||||
standIn(w, r)
|
||||
}))
|
||||
t.Cleanup(f.server.Close)
|
||||
|
||||
f.client = routewatch.New(routewatch.Params{
|
||||
URL: f.server.URL + "/", Now: f.clk.Now,
|
||||
ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)),
|
||||
})
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func answering(body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fixture) route() (routewatch.Route, routewatch.Missing) {
|
||||
addr := netip.MustParseAddr(clientAddr)
|
||||
client := netip.PrefixFrom(addr, addr.BitLen())
|
||||
|
||||
return f.client.Route(context.Background(), client, addr)
|
||||
}
|
||||
|
||||
// waitFor asks until the answer is why, or, while why is "", until there is a
|
||||
// route, and returns the route.
|
||||
func (f *fixture) waitFor(why routewatch.Missing) routewatch.Route {
|
||||
f.t.Helper()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
|
||||
for {
|
||||
route, missing := f.route()
|
||||
if missing == why {
|
||||
return route
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
f.t.Fatalf("still %q after %s, want %q", missing, waitLimit, why)
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// wantFailureLoggedWithoutTheClient waits for the process log to name a failed
|
||||
// query, and checks that it does not name the client.
|
||||
func (f *fixture) wantFailureLoggedWithoutTheClient() {
|
||||
f.t.Helper()
|
||||
|
||||
// The failure is noted before it is logged.
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for !strings.Contains(f.log.String(), "asking routewatch failed") {
|
||||
if time.Now().After(deadline) {
|
||||
f.t.Fatalf("process log %q names no failure", f.log.String())
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
if strings.Contains(f.log.String(), clientAddr) {
|
||||
f.t.Errorf("process log %q names the client", f.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, answering(routedAnswer))
|
||||
|
||||
if _, missing := f.route(); missing != routewatch.NoAnswerYet {
|
||||
t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet)
|
||||
}
|
||||
|
||||
route := f.waitFor("")
|
||||
|
||||
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
|
||||
if route != want {
|
||||
t.Errorf("route %+v, want %+v", route, want)
|
||||
}
|
||||
|
||||
if got := f.path.Load(); got != "/api/v1/ip/"+clientAddr {
|
||||
t.Errorf("asked for path %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(slowAnswer)
|
||||
|
||||
_, _ = w.Write([]byte(routedAnswer))
|
||||
})
|
||||
|
||||
f.route()
|
||||
|
||||
route := f.waitFor("")
|
||||
|
||||
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
|
||||
if route != want {
|
||||
t.Errorf("route %+v, want %+v", route, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddressWithoutARouteIsKeptAsNoRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, answering(noRouteAnswer))
|
||||
|
||||
f.route()
|
||||
f.waitFor(routewatch.NoRoute)
|
||||
|
||||
if got := f.queries.Load(); got != 1 {
|
||||
t.Errorf("%d queries, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := map[string]http.HandlerFunc{
|
||||
"500": func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
},
|
||||
"not JSON": answering("no"),
|
||||
"no results": answering(`{"status":"ok","data":{"results":[]}}`),
|
||||
"bad netblock": answering(
|
||||
`{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`),
|
||||
}
|
||||
for name, standIn := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, standIn)
|
||||
|
||||
f.route()
|
||||
f.waitFor(routewatch.RequestFailed)
|
||||
f.wantFailureLoggedWithoutTheClient()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusedConnectionFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, answering(routedAnswer))
|
||||
|
||||
f.server.Close()
|
||||
|
||||
f.route()
|
||||
f.waitFor(routewatch.RequestFailed)
|
||||
f.wantFailureLoggedWithoutTheClient()
|
||||
}
|
||||
|
||||
func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
f.route()
|
||||
f.waitFor(routewatch.RequestFailed)
|
||||
f.route()
|
||||
|
||||
if got := f.queries.Load(); got != 1 {
|
||||
t.Errorf("%d queries while the failure is kept, want 1", got)
|
||||
}
|
||||
|
||||
f.clk.advance(time.Minute)
|
||||
f.route()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for f.queries.Load() != 2 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load())
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
|
||||
f.route()
|
||||
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("Route took %s while routewatch hangs", elapsed)
|
||||
}
|
||||
|
||||
f.waitFor(routewatch.RequestFailed)
|
||||
f.wantFailureLoggedWithoutTheClient()
|
||||
}
|
||||
|
||||
func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
<-release
|
||||
|
||||
_, _ = w.Write([]byte(routedAnswer))
|
||||
})
|
||||
|
||||
for range 50 {
|
||||
f.route()
|
||||
}
|
||||
|
||||
close(release)
|
||||
f.waitFor("")
|
||||
|
||||
for range 50 {
|
||||
f.route()
|
||||
}
|
||||
|
||||
if got := f.queries.Load(); got != 1 {
|
||||
t.Errorf("%d queries for repeated requests, want 1", got)
|
||||
}
|
||||
|
||||
f.clk.advance(time.Hour)
|
||||
f.waitFor(routewatch.NoAnswerYet)
|
||||
f.waitFor("")
|
||||
|
||||
if got := f.queries.Load(); got != 2 {
|
||||
t.Errorf("%d queries after the answer expired, want 2", got)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
)
|
||||
|
||||
// peerAddress is the address of the request's TCP peer, normally traefik, without
|
||||
// any IPv6 zone, which discriminator refuses.
|
||||
// any IPv6 zone, which servewaf refuses.
|
||||
func peerAddress(r *http.Request) netip.Addr {
|
||||
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
@@ -34,7 +34,7 @@ func peerAddress(r *http.Request) netip.Addr {
|
||||
// address outside them is the visitor; if every address in it is inside, the
|
||||
// leftmost is, and with no header, the peer. An entry that is not an address
|
||||
// ends the reading, since nothing to its left can be believed. The visitor's
|
||||
// address never has an IPv6 zone, which discriminator refuses.
|
||||
// address never has an IPv6 zone, which servewaf refuses.
|
||||
func clientAddress(
|
||||
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
|
||||
) netip.Addr {
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// post is the start of a POST whose headers end with what follows it.
|
||||
@@ -58,7 +58,7 @@ func TestServerHasTheFixedLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing.T) {
|
||||
func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -71,7 +71,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
@@ -100,7 +100,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing
|
||||
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestSize: "1K",
|
||||
@@ -109,7 +109,7 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||
bytes.NewReader(make([]byte, 1025))))
|
||||
if got.status != http.StatusForbidden {
|
||||
t.Errorf("answered %+v, want discriminator's 403", got)
|
||||
t.Errorf("answered %+v, want servewaf's 403", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||
|
||||
var asked atomic.Bool
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
asked.Store(true)
|
||||
greet(w, r)
|
||||
@@ -165,7 +165,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
t.Run("announced, is refused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
|
||||
_, _ = io.WriteString(w, large)
|
||||
@@ -187,7 +187,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
t.Run("not announced, is cut off", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, large[:1])
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -217,7 +217,7 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.
|
||||
|
||||
const announced = "2048"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(contentLengthHeader, announced)
|
||||
})
|
||||
@@ -240,7 +240,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
||||
t.Run(setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
setting: "off",
|
||||
@@ -251,7 +251,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
||||
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||
strings.NewReader("a body"))))
|
||||
|
||||
if got := get(t, addr, discriminator.HealthPath); got.status != http.StatusOK {
|
||||
if got := get(t, addr, servewaf.HealthPath); got.status != http.StatusOK {
|
||||
t.Errorf("answered %+v, want rproxy's own 200", got)
|
||||
}
|
||||
})
|
||||
@@ -270,7 +270,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "500",
|
||||
@@ -289,7 +289,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -300,7 +300,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -314,7 +314,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -365,7 +365,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -404,7 +404,7 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
||||
announced = 100 * questionBodyMaxBytes
|
||||
)
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
@@ -448,13 +448,13 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||
name, target string
|
||||
status int
|
||||
}{
|
||||
{"discriminator's deny", "/", http.StatusForbidden},
|
||||
{"servewaf's deny", "/", http.StatusForbidden},
|
||||
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||
@@ -484,7 +484,7 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "3000",
|
||||
@@ -553,7 +553,7 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
|
||||
writeSize = 32 << 10
|
||||
)
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
|
||||
map[string]string{maxRequestTime: "3000"})
|
||||
|
||||
@@ -580,7 +580,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
<-release
|
||||
})
|
||||
@@ -607,7 +607,7 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
||||
const start = "the start"
|
||||
|
||||
release := make(chan struct{})
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, start)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -656,7 +656,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||
writeSize = 32 << 10
|
||||
)
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
part := make([]byte, writeSize)
|
||||
|
||||
@@ -693,7 +693,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
got := get(t, addr, "/")
|
||||
@@ -709,7 +709,7 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
})
|
||||
@@ -732,7 +732,7 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
working := make(chan struct{})
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
close(working)
|
||||
<-r.Context().Done()
|
||||
@@ -773,7 +773,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
|
||||
|
||||
const answer = "the end of a long answer"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -802,14 +802,14 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
|
||||
{"a deny", post, http.StatusForbidden},
|
||||
{
|
||||
"the health check",
|
||||
"GET " + discriminator.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
||||
"GET " + servewaf.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
||||
http.StatusOK,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
// The time limit is long enough for a loaded host to read the first part of
|
||||
// the body before it runs out.
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// questionBodyMaxBytes is the most of a body a question carries.
|
||||
const questionBodyMaxBytes = discriminator.QuestionBodyMaxBytes
|
||||
const questionBodyMaxBytes = servewaf.QuestionBodyMaxBytes
|
||||
|
||||
// chunked is the encoding of a body whose length the visitor did not announce.
|
||||
const chunked = "chunked"
|
||||
@@ -29,7 +29,7 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
|
||||
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -42,9 +42,9 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
||||
req.Header.Set(forwardedProtoHeader, "gopher")
|
||||
req.Header.Set(requestIDHeader, "the-visitor's")
|
||||
req.Header.Set(discriminator.ClientHeader, "198.51.100.2")
|
||||
req.Header.Set(discriminator.PeerHeader, "198.51.100.3")
|
||||
req.Header.Set(discriminator.ContentLengthHeader, "12345")
|
||||
req.Header.Set(servewaf.ClientHeader, "198.51.100.2")
|
||||
req.Header.Set(servewaf.PeerHeader, "198.51.100.3")
|
||||
req.Header.Set(servewaf.ContentLengthHeader, "12345")
|
||||
// Go's client then sends no User-Agent, so that one in the question would be
|
||||
// Go's own.
|
||||
req.Header["User-Agent"] = nil
|
||||
@@ -68,8 +68,8 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
want.Set(forwardedForHeader, "198.51.100.1")
|
||||
want.Set(forwardedProtoHeader, "http")
|
||||
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
|
||||
want.Set(discriminator.ClientHeader, localhost)
|
||||
want.Set(discriminator.PeerHeader, localhost)
|
||||
want.Set(servewaf.ClientHeader, localhost)
|
||||
want.Set(servewaf.PeerHeader, localhost)
|
||||
|
||||
if !maps.EqualFunc(asked.header, want, slices.Equal) {
|
||||
t.Errorf("the question's headers are %v, want %v", asked.header, want)
|
||||
@@ -93,7 +93,7 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
forwarded := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
forwarded <- r.Header.Clone()
|
||||
@@ -110,8 +110,8 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
asked := standIn.question(t)
|
||||
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
||||
asked.header.Get(discriminator.PeerHeader) != localhost ||
|
||||
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
||||
asked.header.Get(servewaf.PeerHeader) != localhost ||
|
||||
asked.header.Get(forwardedForHeader) != visitor ||
|
||||
asked.header.Get(forwardedProtoHeader) != tc.proto {
|
||||
t.Errorf("the question's headers are %v", asked.header)
|
||||
@@ -157,7 +157,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32,10.0.0.0/8",
|
||||
@@ -167,8 +167,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
||||
req.Header[forwardedForHeader] = tc.header
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
if got := standIn.question(t).header.Get(discriminator.ClientHeader); got !=
|
||||
tc.client {
|
||||
if got := standIn.question(t).header.Get(servewaf.ClientHeader); got != tc.client {
|
||||
t.Errorf("the question names %s, want %s", got, tc.client)
|
||||
}
|
||||
})
|
||||
@@ -183,7 +182,7 @@ func randomBytes(n int) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
|
||||
func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, size := range []int{
|
||||
@@ -198,7 +197,7 @@ func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := randomBytes(size)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
received := make(chan []byte, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
whole, _ := io.ReadAll(r.Body)
|
||||
@@ -244,7 +243,7 @@ func wantQuestionBody(
|
||||
len(want))
|
||||
}
|
||||
|
||||
values := asked.header.Values(discriminator.ContentLengthHeader)
|
||||
values := asked.header.Values(servewaf.ContentLengthHeader)
|
||||
|
||||
if announced {
|
||||
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
|
||||
@@ -265,7 +264,7 @@ func wantQuestionBody(
|
||||
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -276,7 +275,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||
strings.Repeat("x", questionBodyMaxBytes))
|
||||
|
||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||
t.Errorf("status %d, want discriminator's 403", status)
|
||||
t.Errorf("status %d, want servewaf's 403", status)
|
||||
}
|
||||
|
||||
if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
|
||||
@@ -307,14 +306,14 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
|
||||
t.Run(method+" "+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
|
||||
|
||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||
t.Errorf("status %d, want discriminator's 403", status)
|
||||
t.Errorf("status %d, want servewaf's 403", status)
|
||||
}
|
||||
|
||||
asked := standIn.question(t)
|
||||
@@ -335,7 +334,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
@@ -354,7 +353,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -362,7 +361,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||
func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, method := range []string{
|
||||
@@ -373,7 +372,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reached := make(chan string, 1)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
reached <- r.Method
|
||||
})
|
||||
@@ -385,7 +384,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||
}
|
||||
|
||||
if asked := standIn.question(t); asked.method != method {
|
||||
t.Errorf("discriminator was asked about %s", asked.method)
|
||||
t.Errorf("servewaf was asked about %s", asked.method)
|
||||
}
|
||||
|
||||
if atApp := <-reached; atApp != method {
|
||||
@@ -395,7 +394,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||
func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const host = "Host: site.example\r\n\r\n"
|
||||
@@ -417,7 +416,7 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -435,7 +434,7 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -443,12 +442,12 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) {
|
||||
func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const host = "a%b"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -469,7 +468,7 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
|
||||
|
||||
const want = "http"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
@@ -498,7 +497,7 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
@@ -511,8 +510,8 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
asked := standIn.question(t)
|
||||
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
||||
asked.header.Get(discriminator.PeerHeader) != tc.peer {
|
||||
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
||||
asked.header.Get(servewaf.PeerHeader) != tc.peer {
|
||||
t.Errorf("the question's headers are %v", asked.header)
|
||||
}
|
||||
|
||||
|
||||
+19
-19
@@ -16,16 +16,16 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
|
||||
// soon as it arrives.
|
||||
const flushAfterEachWrite time.Duration = -1
|
||||
|
||||
// reportStatusMax is the highest status discriminator takes in a report. An app can
|
||||
// reportStatusMax is the highest status servewaf takes in a report. An app can
|
||||
// send a higher one, up to 999, and the visitor gets that as it is.
|
||||
const reportStatusMax = 599
|
||||
|
||||
@@ -49,14 +49,14 @@ type request struct {
|
||||
peerTrusted bool
|
||||
start time.Time
|
||||
|
||||
// disposition is what is done with the request: discriminator's valid disposition,
|
||||
// disposition is what is done with the request: servewaf's valid disposition,
|
||||
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
||||
disposition string
|
||||
// answered is true when discriminator gave disposition, which makes a report due,
|
||||
// answered is true when servewaf gave disposition, which makes a report due,
|
||||
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
||||
answered bool
|
||||
byDefault bool
|
||||
// asn and country are discriminator's X-Client-ASN and X-Client-Country, for the
|
||||
// asn and country are servewaf's X-Client-ASN and X-Client-Country, for the
|
||||
// app.
|
||||
asn, country string
|
||||
|
||||
@@ -122,14 +122,14 @@ func (rq *request) answerHealth() {
|
||||
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
||||
// visitor that went away or stopped its sending side, the connection is closed
|
||||
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
||||
// read, so that discriminator is asked first; passToApp then refuses it.
|
||||
// read, so that servewaf is asked first; passToApp then refuses it.
|
||||
func (rq *request) readFirstPartOfBody() bool {
|
||||
if rq.body == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
|
||||
held, err := io.ReadAll(io.LimitReader(rq.body, discriminator.QuestionBodyMaxBytes))
|
||||
held, err := io.ReadAll(io.LimitReader(rq.body, servewaf.QuestionBodyMaxBytes))
|
||||
// From here on, the request timer ends a read of the rest of the body, so that
|
||||
// the answer can name the side rproxy was waiting on.
|
||||
_ = rq.rc.SetReadDeadline(time.Time{})
|
||||
@@ -192,7 +192,7 @@ func (rq *request) forward(ctx context.Context) {
|
||||
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
||||
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
||||
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
||||
// discriminator's.
|
||||
// servewaf's.
|
||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||
upstream := rq.h.config.UpstreamURL
|
||||
pr.Out.URL.Scheme = upstream.Scheme
|
||||
@@ -312,7 +312,7 @@ func (rq *request) answer(status int) {
|
||||
// handler has returned, after finish has written the log line and sent the report.
|
||||
// Without Content-Length, the flush would send the body in chunks, the last of
|
||||
// which would still wait for the handler. A HEAD's answer has no body, so any
|
||||
// Content-Length it has stays as discriminator gave it.
|
||||
// Content-Length it has stays as servewaf gave it.
|
||||
func (rq *request) send(status int, body []byte) {
|
||||
if rq.in.Method != http.MethodHead {
|
||||
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
@@ -347,7 +347,7 @@ func (rq *request) refuse(status int) {
|
||||
}
|
||||
}
|
||||
|
||||
// finish ends the request's time limits, writes its log line and, if discriminator
|
||||
// finish ends the request's time limits, writes its log line and, if servewaf
|
||||
// answered the question, sends the report. ctx is the request's own context.
|
||||
func (rq *request) finish(ctx context.Context) {
|
||||
rq.stopTimers()
|
||||
@@ -371,8 +371,8 @@ func (rq *request) finish(ctx context.Context) {
|
||||
Status: rq.out.status,
|
||||
RequestBytes: requestBytes,
|
||||
ResponseBytes: responseBytes,
|
||||
DurationRequestMS: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
|
||||
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
|
||||
DurationRequest: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
|
||||
DurationResponse: requestlog.Milliseconds(end.Sub(requestEnd)),
|
||||
Disposition: rq.disposition,
|
||||
DefaultDisposition: rq.byDefault,
|
||||
})
|
||||
@@ -384,15 +384,15 @@ func (rq *request) finish(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
report := &discriminator.Report{
|
||||
RequestID: rq.id,
|
||||
Status: min(rq.out.status, reportStatusMax),
|
||||
RequestBytes: requestBytes,
|
||||
ResponseBytes: responseBytes,
|
||||
DurationTotalMS: requestlog.Milliseconds(end.Sub(rq.start)),
|
||||
report := &servewaf.Report{
|
||||
RequestID: rq.id,
|
||||
Status: min(rq.out.status, reportStatusMax),
|
||||
RequestBytes: requestBytes,
|
||||
ResponseBytes: responseBytes,
|
||||
DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)),
|
||||
}
|
||||
if !rq.upstreamStart.IsZero() {
|
||||
report.DurationUpstreamTotalMS = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
|
||||
report.DurationUpstreamTotal = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
|
||||
}
|
||||
|
||||
rq.h.sendReport(ctx, report)
|
||||
|
||||
+35
-35
@@ -1,10 +1,10 @@
|
||||
// Package rproxy is the reverse proxy in front of one app that asks discriminator
|
||||
// about each request. It passes a request discriminator allows to the app and the
|
||||
// Package rproxy is the reverse proxy in front of one app that asks servewaf
|
||||
// about each request. It passes a request servewaf allows to the app and the
|
||||
// app's answer back, within the size and time limits. Any other answer from
|
||||
// discriminator goes to the visitor as discriminator gave it. SWRP_DEFAULT_DISPOSITION
|
||||
// decides only when discriminator cannot be connected to, does not answer at all, or
|
||||
// servewaf goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
|
||||
// decides only when servewaf cannot be connected to, does not answer at all, or
|
||||
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||
// method it does not pass on, or one it cannot put to discriminator as the visitor
|
||||
// method it does not pass on, or one it cannot put to servewaf as the visitor
|
||||
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
||||
// SPEC.md says what it does.
|
||||
package rproxy
|
||||
@@ -22,10 +22,10 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// How rproxy keeps connections to the app and to discriminator open between
|
||||
// How rproxy keeps connections to the app and to servewaf open between
|
||||
// requests.
|
||||
const (
|
||||
idleConns = 100
|
||||
@@ -45,9 +45,9 @@ const (
|
||||
idleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// ownEndpointPrefix starts the path of every request for sww itself, which
|
||||
// adminPrefix starts the path of every request for smallwebwaf itself, which
|
||||
// never reaches the app.
|
||||
const ownEndpointPrefix = "/_sww/"
|
||||
const adminPrefix = "/_smallwebwaf/"
|
||||
|
||||
// allowedMethods lists the methods rproxy passes on, as an Allow header gives
|
||||
// them. It answers any other method 405 itself.
|
||||
@@ -62,14 +62,14 @@ type Params struct {
|
||||
ProcessLog *slog.Logger
|
||||
}
|
||||
|
||||
// Server is the server `sww rproxy` runs.
|
||||
// Server is the server `smallwebwaf rproxy` runs.
|
||||
type Server struct {
|
||||
*http.Server
|
||||
|
||||
h *handler
|
||||
}
|
||||
|
||||
// New returns the server `sww rproxy` runs. Go's server itself refuses
|
||||
// New returns the server `smallwebwaf rproxy` runs. Go's server itself refuses
|
||||
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
||||
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
|
||||
// the headers arrive. The handler applies the time and size limits from then
|
||||
@@ -78,14 +78,14 @@ func New(params Params) *Server {
|
||||
backend := params.Config.WAFBackend
|
||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||
h := &handler{
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
errorLog: errorLog,
|
||||
transport: newTransport(),
|
||||
discriminatorTransport: newDiscriminatorTransport(),
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
errorLog: errorLog,
|
||||
transport: newTransport(),
|
||||
serveWAFTransport: newServeWAFTransport(),
|
||||
reportURL: (&url.URL{
|
||||
Scheme: backend.Scheme, Host: backend.Host, Path: discriminator.ReportPath,
|
||||
Scheme: backend.Scheme, Host: backend.Host, Path: servewaf.ReportPath,
|
||||
}).String(),
|
||||
}
|
||||
|
||||
@@ -120,10 +120,10 @@ type handler struct {
|
||||
requestLog io.Writer
|
||||
processLog *slog.Logger
|
||||
errorLog *log.Logger
|
||||
// transport carries requests to the app, and discriminatorTransport questions and
|
||||
// reports to discriminator.
|
||||
transport http.RoundTripper
|
||||
discriminatorTransport http.RoundTripper
|
||||
// transport carries requests to the app, and serveWAFTransport questions and
|
||||
// reports to servewaf.
|
||||
transport http.RoundTripper
|
||||
serveWAFTransport http.RoundTripper
|
||||
// reportURL is where the reports go.
|
||||
reportURL string
|
||||
// reports waits for the reports being sent. reportsMu guards starting one
|
||||
@@ -145,11 +145,11 @@ func newTransport() *http.Transport {
|
||||
}
|
||||
}
|
||||
|
||||
// newDiscriminatorTransport returns what carries questions and reports to
|
||||
// discriminator. It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send
|
||||
// a visitor's Upgrade header, or a Connection header other than close or
|
||||
// keep-alive, and that would read as discriminator giving no answer.
|
||||
func newDiscriminatorTransport() *http.Transport {
|
||||
// newServeWAFTransport returns what carries questions and reports to servewaf.
|
||||
// It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send a visitor's Upgrade
|
||||
// header, or a Connection header other than close or keep-alive, and that would
|
||||
// read as servewaf giving no answer.
|
||||
func newServeWAFTransport() *http.Transport {
|
||||
transport := newTransport()
|
||||
transport.Protocols = new(http.Protocols)
|
||||
transport.Protocols.SetHTTP1(true)
|
||||
@@ -158,15 +158,15 @@ func newDiscriminatorTransport() *http.Transport {
|
||||
}
|
||||
|
||||
// ServeHTTP handles one request: it answers the health check itself, refuses a
|
||||
// request with a method it does not pass on or one it cannot put to discriminator as
|
||||
// request with a method it does not pass on or one it cannot put to servewaf as
|
||||
// the visitor sent it, and for any other request reads the first part of the
|
||||
// body, asks discriminator, and does what the answer says. Once the answer has ended,
|
||||
// body, asks servewaf, and does what the answer says. Once the answer has ended,
|
||||
// it writes the request's log line and sends its report.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq := h.newRequest(w, r)
|
||||
defer rq.finish(r.Context())
|
||||
|
||||
if r.Method == http.MethodGet && r.URL.Path == discriminator.HealthPath {
|
||||
if r.Method == http.MethodGet && r.URL.Path == servewaf.HealthPath {
|
||||
rq.answerHealth()
|
||||
|
||||
return
|
||||
@@ -199,9 +199,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq.noteAnswer(answer)
|
||||
|
||||
switch rq.disposition {
|
||||
case discriminator.DispositionAllow:
|
||||
case servewaf.DispositionAllow:
|
||||
rq.passToApp(r.Context())
|
||||
case discriminator.DispositionClose:
|
||||
case servewaf.DispositionClose:
|
||||
// This panic has Go's server close the connection without an answer,
|
||||
// and log nothing; the deferred finish still runs.
|
||||
panic(http.ErrAbortHandler)
|
||||
@@ -211,8 +211,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// passToApp passes the request on to the app, unless a limit or its path keeps
|
||||
// it from the app. discriminator answers a path under ownEndpointPrefix itself, so
|
||||
// such a request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||
// it from the app. servewaf answers a path under adminPrefix itself, so such a
|
||||
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||
// request's own context.
|
||||
func (rq *request) passToApp(ctx context.Context) {
|
||||
maxBytes := rq.h.config.MaxRequestSize
|
||||
@@ -220,7 +220,7 @@ func (rq *request) passToApp(ctx context.Context) {
|
||||
switch {
|
||||
case rq.refused.Load() != 0 || (maxBytes > 0 && rq.in.ContentLength > maxBytes):
|
||||
rq.answer(http.StatusRequestEntityTooLarge)
|
||||
case strings.HasPrefix(rq.in.URL.Path, ownEndpointPrefix):
|
||||
case strings.HasPrefix(rq.in.URL.Path, adminPrefix):
|
||||
rq.answer(http.StatusNotFound)
|
||||
default:
|
||||
rq.forward(ctx)
|
||||
|
||||
+112
-116
@@ -19,9 +19,9 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,27 +54,27 @@ const (
|
||||
|
||||
// The headers the tests set and look at.
|
||||
const (
|
||||
dispositionHeader = discriminator.DispositionHeader
|
||||
dispositionHeader = servewaf.DispositionHeader
|
||||
forwardedForHeader = "X-Forwarded-For"
|
||||
forwardedProtoHeader = "X-Forwarded-Proto"
|
||||
requestIDHeader = "X-Request-ID"
|
||||
asnHeader = "X-Client-Asn"
|
||||
countryHeader = "X-Client-Country"
|
||||
contentLengthHeader = "Content-Length"
|
||||
// fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny.
|
||||
fromDiscriminatorHeader = "X-From-Discriminator"
|
||||
// fromServeWAFHeader is a header the stand-in for servewaf adds to a deny.
|
||||
fromServeWAFHeader = "X-From-Servewaf"
|
||||
// addedHeaderValue is the value of a header a test adds to an answer, to see it
|
||||
// reach the visitor.
|
||||
addedHeaderValue = "yes"
|
||||
)
|
||||
|
||||
// What the stand-in for discriminator answers.
|
||||
// What the stand-in for servewaf answers.
|
||||
const (
|
||||
dispositionAllow = discriminator.DispositionAllow
|
||||
dispositionDeny = discriminator.DispositionDeny
|
||||
dispositionClose = discriminator.DispositionClose
|
||||
dispositionAllow = servewaf.DispositionAllow
|
||||
dispositionDeny = servewaf.DispositionDeny
|
||||
dispositionClose = servewaf.DispositionClose
|
||||
// deniedBody is the body of a deny.
|
||||
deniedBody = "refused by discriminator"
|
||||
deniedBody = "refused by servewaf"
|
||||
)
|
||||
|
||||
// output collects what rproxy writes on stdout.
|
||||
@@ -158,7 +158,7 @@ func (l heldLog) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// question is what the stand-in for discriminator received as a question.
|
||||
// question is what the stand-in for servewaf received as a question.
|
||||
type question struct {
|
||||
method, target, host string
|
||||
header http.Header
|
||||
@@ -167,26 +167,26 @@ type question struct {
|
||||
transferEncoding []string
|
||||
}
|
||||
|
||||
// Discriminator is a stand-in for discriminator that answers each question as
|
||||
// answer does, and keeps the questions and the reports it gets.
|
||||
type Discriminator struct {
|
||||
// serveWAF is a stand-in for servewaf that answers each question as answer
|
||||
// does, and keeps the questions and the reports it gets.
|
||||
type serveWAF struct {
|
||||
*httptest.Server
|
||||
|
||||
questions chan question
|
||||
reports chan discriminator.Report
|
||||
reports chan servewaf.Report
|
||||
}
|
||||
|
||||
// startDiscriminator starts a stand-in for discriminator.
|
||||
func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
|
||||
// startServeWAF starts a stand-in for servewaf.
|
||||
func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
|
||||
t.Helper()
|
||||
|
||||
s := &Discriminator{
|
||||
s := &serveWAF{
|
||||
questions: make(chan question, 10),
|
||||
reports: make(chan discriminator.Report, 10),
|
||||
reports: make(chan servewaf.Report, 10),
|
||||
}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(discriminator.ClientHeader) == "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) == "" {
|
||||
s.takeReport(t, w, r)
|
||||
|
||||
return
|
||||
@@ -209,17 +209,14 @@ func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
|
||||
return s
|
||||
}
|
||||
|
||||
// takeReport keeps a report and answers it as discriminator does.
|
||||
func (s *Discriminator) takeReport(
|
||||
t *testing.T, w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
// takeReport keeps a report and answers it as servewaf does.
|
||||
func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
||||
t.Helper()
|
||||
|
||||
var report discriminator.Report
|
||||
var report servewaf.Report
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&report)
|
||||
if err != nil || r.Method != http.MethodPost ||
|
||||
r.URL.Path != discriminator.ReportPath {
|
||||
if err != nil || r.Method != http.MethodPost || r.URL.Path != servewaf.ReportPath {
|
||||
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
|
||||
}
|
||||
|
||||
@@ -229,7 +226,7 @@ func (s *Discriminator) takeReport(
|
||||
}
|
||||
|
||||
// wantNoReport checks that no report comes for a moment.
|
||||
func (s *Discriminator) wantNoReport(t *testing.T) {
|
||||
func (s *serveWAF) wantNoReport(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
@@ -240,7 +237,7 @@ func (s *Discriminator) wantNoReport(t *testing.T) {
|
||||
}
|
||||
|
||||
// report waits for a report.
|
||||
func (s *Discriminator) report(t *testing.T) discriminator.Report {
|
||||
func (s *serveWAF) report(t *testing.T) servewaf.Report {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
@@ -249,12 +246,12 @@ func (s *Discriminator) report(t *testing.T) discriminator.Report {
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("no report")
|
||||
|
||||
return discriminator.Report{}
|
||||
return servewaf.Report{}
|
||||
}
|
||||
}
|
||||
|
||||
// question waits for a question.
|
||||
func (s *Discriminator) question(t *testing.T) question {
|
||||
func (s *serveWAF) question(t *testing.T) question {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
@@ -267,17 +264,16 @@ func (s *Discriminator) question(t *testing.T) question {
|
||||
}
|
||||
}
|
||||
|
||||
// startDiscriminatorAnsweringReports starts a stand-in for discriminator that
|
||||
// answers each question as answer does and each report as report does, and
|
||||
// returns its URL.
|
||||
func startDiscriminatorAnsweringReports(
|
||||
// startServeWAFAnsweringReports starts a stand-in for servewaf that answers each
|
||||
// question as answer does and each report as report does, and returns its URL.
|
||||
func startServeWAFAnsweringReports(
|
||||
t *testing.T, answer, report http.HandlerFunc,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(discriminator.ClientHeader) != "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||
answer(w, r)
|
||||
|
||||
return
|
||||
@@ -299,7 +295,7 @@ func allow(w http.ResponseWriter, _ *http.Request) {
|
||||
// deny answers a question with deny, status 403 and a body of its own.
|
||||
func deny(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(dispositionHeader, dispositionDeny)
|
||||
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
||||
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, deniedBody)
|
||||
}
|
||||
@@ -320,7 +316,7 @@ func greet(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
}
|
||||
|
||||
// startRProxy starts rproxy with discriminator at backend and the app at appURL, and
|
||||
// startRProxy starts rproxy with servewaf at backend and the app at appURL, and
|
||||
// the settings in env on top of the defaults. It returns where rproxy listens
|
||||
// and what it writes.
|
||||
func startRProxy(
|
||||
@@ -502,7 +498,7 @@ func readStatus(t *testing.T, conn net.Conn) int {
|
||||
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(asnHeader, "AS64496")
|
||||
w.Header().Set(countryHeader, "DE")
|
||||
allow(w, r)
|
||||
@@ -561,7 +557,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
||||
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
||||
}
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
seen := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seen <- r.Header.Clone()
|
||||
@@ -606,7 +602,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
||||
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
seen := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seen <- r.Header.Clone()
|
||||
@@ -645,7 +641,7 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
||||
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header()["Content-Type"] = nil
|
||||
_, _ = io.WriteString(w, "<html>")
|
||||
@@ -667,19 +663,19 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T) {
|
||||
func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
backend func(t *testing.T) string
|
||||
}{
|
||||
{"discriminator allows", func(t *testing.T) string {
|
||||
{"servewaf allows", func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return startDiscriminator(t, allow).URL
|
||||
return startServeWAF(t, allow).URL
|
||||
}},
|
||||
{"discriminator is down", func(*testing.T) string { return nowhere }},
|
||||
{"servewaf is down", func(*testing.T) string { return nowhere }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -705,10 +701,10 @@ func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
||||
func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
@@ -716,8 +712,8 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
||||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
||||
t.Errorf("answered %+v, want discriminator's answer", got)
|
||||
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
||||
t.Errorf("answered %+v, want servewaf's answer", got)
|
||||
}
|
||||
|
||||
if len(got.header.Values(dispositionHeader)) != 0 {
|
||||
@@ -727,27 +723,27 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
||||
report := standIn.report(t)
|
||||
if report.Status != http.StatusForbidden ||
|
||||
report.ResponseBytes != int64(len(deniedBody)) ||
|
||||
report.DurationUpstreamTotalMS != 0 {
|
||||
report.DurationUpstreamTotal != 0 {
|
||||
t.Errorf("report %+v, of a request denied", report)
|
||||
}
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
|
||||
t.Errorf("line %v, want discriminator's deny", line)
|
||||
t.Errorf("line %v, want servewaf's deny", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeniedHeadKeepsDiscriminatorsContentLength(t *testing.T) {
|
||||
func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
||||
if got.status != http.StatusForbidden ||
|
||||
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
|
||||
t.Errorf("answered %+v, want discriminator's 403 and its length", got)
|
||||
t.Errorf("answered %+v, want servewaf's 403 and its length", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,13 +758,13 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
||||
}{
|
||||
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
|
||||
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
|
||||
{"the health check", "GET " + discriminator.HealthPath + host, "ok\n", http.StatusOK},
|
||||
{"the health check", "GET " + servewaf.HealthPath + host, "ok\n", http.StatusOK},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := heldLog{release: make(chan struct{})}
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
|
||||
|
||||
@@ -798,7 +794,7 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
||||
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -821,7 +817,7 @@ func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
||||
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(dispositionHeader, dispositionClose)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
@@ -853,7 +849,7 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := startDiscriminatorAnsweringReports(t,
|
||||
backend := startServeWAFAnsweringReports(t,
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(dispositionHeader, dispositionClose)
|
||||
},
|
||||
@@ -871,24 +867,24 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
||||
|
||||
line := out.requestLine(t)
|
||||
|
||||
duration, ok := line["duration_response_ms"].(float64)
|
||||
duration, ok := line["duration_response"].(float64)
|
||||
if !ok || duration < 0 {
|
||||
t.Errorf("line %v, want a duration_response_ms that is not negative", line)
|
||||
t.Errorf("line %v, want a duration_response that is not negative", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// answeringWith returns what starts a stand-in for discriminator that answers each
|
||||
// answeringWith returns what starts a stand-in for servewaf that answers each
|
||||
// question as answer does, and returns its URL.
|
||||
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
||||
return func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return startDiscriminator(t, answer).URL
|
||||
return startServeWAF(t, answer).URL
|
||||
}
|
||||
}
|
||||
|
||||
// noAnswers are the ways discriminator can leave the decision to the default.
|
||||
// noAnswers are the ways servewaf can leave the decision to the default.
|
||||
func noAnswers() map[string]func(t *testing.T) string {
|
||||
return map[string]func(t *testing.T) string{
|
||||
"no connection": func(*testing.T) string { return nowhere },
|
||||
@@ -920,7 +916,7 @@ func noAnswers() map[string]func(t *testing.T) string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.T) {
|
||||
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -929,7 +925,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
}{
|
||||
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
|
||||
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
|
||||
{"discriminator's 400", "", "refused\n", http.StatusBadRequest},
|
||||
{"servewaf's 400", "", "refused\n", http.StatusBadRequest},
|
||||
{"a 500", "", "broken\n", http.StatusInternalServerError},
|
||||
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
|
||||
{"no header", "", "who knows\n", http.StatusOK},
|
||||
@@ -939,12 +935,12 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
if tc.disposition != "" {
|
||||
w.Header().Set(dispositionHeader, tc.disposition)
|
||||
}
|
||||
|
||||
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
||||
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
})
|
||||
@@ -957,8 +953,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if got.status != tc.status || got.body != tc.body ||
|
||||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
||||
t.Errorf("answered %+v, want discriminator's answer", got)
|
||||
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
||||
t.Errorf("answered %+v, want servewaf's answer", got)
|
||||
}
|
||||
|
||||
if len(got.header.Values(dispositionHeader)) != 0 {
|
||||
@@ -970,8 +966,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
t.Errorf("line %v, want status %d and no default", line, tc.status)
|
||||
}
|
||||
|
||||
if out.has("discriminator gave no answer") {
|
||||
t.Error("rproxy warned that discriminator gave no answer")
|
||||
if out.has("servewaf gave no answer") {
|
||||
t.Error("rproxy warned that servewaf gave no answer")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -981,14 +977,14 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "refused", http.StatusBadRequest)
|
||||
})
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
|
||||
t.Errorf("answered %+v, want discriminator's 400", got)
|
||||
t.Errorf("answered %+v, want servewaf's 400", got)
|
||||
}
|
||||
|
||||
if line := out.requestLine(t); line["disposition"] != nil {
|
||||
@@ -1001,7 +997,7 @@ func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
||||
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header()["Content-Type"] = nil
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, "<html>broken")
|
||||
@@ -1019,7 +1015,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
||||
|
||||
const size = 1 << 20
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write(make([]byte, size))
|
||||
})
|
||||
@@ -1027,15 +1023,15 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
||||
t.Errorf("answered %d with %d bytes, want discriminator's 500 with %d",
|
||||
t.Errorf("answered %d with %d bytes, want servewaf's 500 with %d",
|
||||
got.status, len(got.body), size)
|
||||
}
|
||||
}
|
||||
|
||||
// startDiscriminatorWriting starts a stand-in for discriminator that reads
|
||||
// each question and answers it with text, as it is, and returns its URL. It
|
||||
// can send what Go's server would not.
|
||||
func startDiscriminatorWriting(t *testing.T, text string) string {
|
||||
// startServeWAFWriting starts a stand-in for servewaf that reads each question and
|
||||
// answers it with text, as it is, and returns its URL. It can send what Go's
|
||||
// server would not.
|
||||
func startServeWAFWriting(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
@@ -1105,7 +1101,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
"a status below 100": func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return startDiscriminatorWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
||||
return startServeWAFWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
||||
},
|
||||
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
||||
} {
|
||||
@@ -1130,7 +1126,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
t.Errorf("line %v, want status 502 and no disposition", line)
|
||||
}
|
||||
|
||||
out.waitFor(t, "discriminator's answer is dropped")
|
||||
out.waitFor(t, "servewaf's answer is dropped")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1139,7 +1135,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, overOneMiB(dispositionDeny))
|
||||
standIn := startServeWAF(t, overOneMiB(dispositionDeny))
|
||||
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
||||
@@ -1196,14 +1192,14 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T) {
|
||||
func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
||||
t.Run("with the default "+disposition, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
})
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
@@ -1226,8 +1222,8 @@ func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T
|
||||
t.Errorf("line %v, want status 0 and no disposition", line)
|
||||
}
|
||||
|
||||
if out.has("discriminator gave no answer") {
|
||||
t.Error("rproxy warned that discriminator gave no answer")
|
||||
if out.has("servewaf gave no answer") {
|
||||
t.Error("rproxy warned that servewaf gave no answer")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -1242,13 +1238,13 @@ func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
||||
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
out.waitFor(t, "discriminator gave no answer")
|
||||
out.waitFor(t, "servewaf gave no answer")
|
||||
}
|
||||
|
||||
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
})
|
||||
app := startApp(t, greet)
|
||||
@@ -1269,35 +1265,35 @@ func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
||||
})
|
||||
addr, _ := startRProxy(t, nowhere, app.URL, nil)
|
||||
|
||||
got := get(t, addr, "/_sww/metrics")
|
||||
got := get(t, addr, "/_smallwebwaf/metrics")
|
||||
if got.status != http.StatusNotFound {
|
||||
t.Errorf("answered %+v, want 404", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckIsAnsweredWhileDiscriminatorIsDown(t *testing.T) {
|
||||
func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
||||
|
||||
got := get(t, addr, discriminator.HealthPath)
|
||||
got := get(t, addr, servewaf.HealthPath)
|
||||
if got.status != http.StatusOK || got.body != "ok\n" {
|
||||
t.Errorf("answered %+v, want ok", got)
|
||||
}
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["path"] != discriminator.HealthPath || line["disposition"] != nil {
|
||||
if line["path"] != servewaf.HealthPath || line["disposition"] != nil {
|
||||
t.Errorf("line %v, want one with no disposition", line)
|
||||
}
|
||||
|
||||
// With discriminator up too, it is not asked.
|
||||
// With servewaf up too, it is not asked.
|
||||
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
||||
get(t, addr, discriminator.HealthPath)
|
||||
get(t, addr, servewaf.HealthPath)
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked about the health check")
|
||||
t.Error("servewaf was asked about the health check")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1305,7 +1301,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
backend := startDiscriminatorAnsweringReports(t, deny,
|
||||
backend := startServeWAFAnsweringReports(t, deny,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
@@ -1340,16 +1336,16 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status %d, want discriminator's 403", res.StatusCode)
|
||||
t.Errorf("status %d, want servewaf's 403", res.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
||||
func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
backend := startDiscriminatorAnsweringReports(t, allow,
|
||||
backend := startServeWAFAnsweringReports(t, allow,
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
<-release
|
||||
|
||||
@@ -1369,10 +1365,10 @@ func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.
|
||||
out.waitFor(t, "a report is dropped")
|
||||
}
|
||||
|
||||
func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
|
||||
func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := startDiscriminatorAnsweringReports(t, allow,
|
||||
backend := startServeWAFAnsweringReports(t, allow,
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
})
|
||||
@@ -1387,7 +1383,7 @@ func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
|
||||
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := startDiscriminatorAnsweringReports(t, allow, switchAndHold)
|
||||
backend := startServeWAFAnsweringReports(t, allow, switchAndHold)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||
timeoutSetting: "60000",
|
||||
@@ -1400,7 +1396,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
||||
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -1416,7 +1412,7 @@ func TestReportGivesWhatHappened(t *testing.T) {
|
||||
|
||||
const body = "the visitor's body"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -1428,8 +1424,8 @@ func TestReportGivesWhatHappened(t *testing.T) {
|
||||
if report.RequestID == "" || report.Status != http.StatusOK ||
|
||||
report.RequestBytes != int64(len(body)) ||
|
||||
report.ResponseBytes != int64(len(greeting)) ||
|
||||
report.DurationTotalMS <= 0 || report.DurationUpstreamTotalMS <= 0 ||
|
||||
report.DurationUpstreamTotalMS > report.DurationTotalMS {
|
||||
report.DurationTotal <= 0 || report.DurationUpstreamTotal <= 0 ||
|
||||
report.DurationUpstreamTotal > report.DurationTotal {
|
||||
t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting))
|
||||
}
|
||||
|
||||
@@ -1446,7 +1442,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
backend := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(discriminator.ClientHeader) != "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||
allow(w, r)
|
||||
|
||||
return
|
||||
@@ -1501,7 +1497,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
||||
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, echoAfterUpgrade)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "2000",
|
||||
@@ -1556,7 +1552,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
||||
|
||||
const goodbye = "sent after the visitor stopped sending\n"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
@@ -1614,7 +1610,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
||||
//nolint:paralleltest // see above
|
||||
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
||||
closed := make(chan error, 1)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
@@ -1685,7 +1681,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
|
||||
|
||||
const fromTraefik = "an-id-from-traefik"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
@@ -1715,7 +1711,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
||||
body = "the visitor's body"
|
||||
)
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -1748,7 +1744,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
||||
t.Errorf("default_disposition is %v, want it left out", line["default_disposition"])
|
||||
}
|
||||
|
||||
for _, name := range []string{"duration_request_ms", "duration_response_ms"} {
|
||||
for _, name := range []string{"duration_request", "duration_response"} {
|
||||
duration, ok := line[name].(float64)
|
||||
if !ok || duration <= 0 {
|
||||
t.Errorf("%s is %v, want a time", name, line[name])
|
||||
|
||||
@@ -11,15 +11,15 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// answerBodyMaxBytes is the most of the body of discriminator's answer that is read.
|
||||
// answerBodyMaxBytes is the most of the body of servewaf's answer that is read.
|
||||
// It is far above any answer's.
|
||||
const answerBodyMaxBytes = 1 << 20
|
||||
|
||||
// reportAnswerMaxBytes is the most of the answer to a report that is read,
|
||||
// which has no body when discriminator takes the report.
|
||||
// which has no body when servewaf takes the report.
|
||||
const reportAnswerMaxBytes = 4 << 10
|
||||
|
||||
var (
|
||||
@@ -27,22 +27,21 @@ var (
|
||||
errReportRefused = errors.New("the report was answered")
|
||||
)
|
||||
|
||||
// discriminatorAnswer is what discriminator answered to a question.
|
||||
type discriminatorAnswer struct {
|
||||
// servewafAnswer is what servewaf answered to a question.
|
||||
type servewafAnswer struct {
|
||||
status int
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// noteAnswer notes what discriminator answered. An answer without a valid disposition
|
||||
// notes nothing, and no report is due for it: discriminator holds no decision to report
|
||||
// noteAnswer notes what servewaf answered. An answer without a valid disposition
|
||||
// notes nothing, and no report is due for it: servewaf holds no decision to report
|
||||
// on.
|
||||
func (rq *request) noteAnswer(answer *discriminatorAnswer) {
|
||||
disposition := answer.header.Get(discriminator.DispositionHeader)
|
||||
func (rq *request) noteAnswer(answer *servewafAnswer) {
|
||||
disposition := answer.header.Get(servewaf.DispositionHeader)
|
||||
|
||||
switch disposition {
|
||||
case discriminator.DispositionAllow, discriminator.DispositionDeny,
|
||||
discriminator.DispositionClose:
|
||||
case servewaf.DispositionAllow, servewaf.DispositionDeny, servewaf.DispositionClose:
|
||||
rq.disposition = disposition
|
||||
rq.answered = true
|
||||
rq.asn = answer.header.Get(asnHeader)
|
||||
@@ -55,27 +54,27 @@ func (rq *request) noteAnswer(answer *discriminatorAnswer) {
|
||||
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
// The visitor went away while discriminator was being asked. discriminator has not
|
||||
// The visitor went away while servewaf was being asked. servewaf has not
|
||||
// failed, and nothing is noted. This panic has Go's server close the
|
||||
// connection without an answer, and log nothing; the deferred finish still
|
||||
// runs.
|
||||
panic(http.ErrAbortHandler)
|
||||
case errors.Is(err, errCannotPassOn):
|
||||
rq.h.processLog.Warn("discriminator's answer is dropped", "error", err.Error())
|
||||
rq.h.processLog.Warn("servewaf's answer is dropped", "error", err.Error())
|
||||
rq.answer(http.StatusBadGateway)
|
||||
default:
|
||||
// discriminator cannot be connected to, does not answer at all, or does not answer
|
||||
// within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past
|
||||
// SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets
|
||||
// servewaf cannot be connected to, does not answer at all, or does not answer
|
||||
// within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past
|
||||
// SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets
|
||||
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
||||
// whenever discriminator is slow. Making such requests hard to send is
|
||||
// whenever servewaf is slow. Making such requests hard to send is
|
||||
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
||||
rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error())
|
||||
rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error())
|
||||
|
||||
rq.disposition = rq.h.config.DefaultDisposition
|
||||
rq.byDefault = true
|
||||
|
||||
if rq.disposition == discriminator.DispositionAllow {
|
||||
if rq.disposition == servewaf.DispositionAllow {
|
||||
rq.passToApp(ctx)
|
||||
} else {
|
||||
rq.answer(http.StatusServiceUnavailable)
|
||||
@@ -83,10 +82,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ask sends discriminator the question, and returns its answer, which must have come
|
||||
// ask sends servewaf the question, and returns its answer, which must have come
|
||||
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
||||
// away.
|
||||
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
||||
func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -95,7 +94,7 @@ func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := rq.h.discriminatorTransport.RoundTrip(question)
|
||||
res, err := rq.h.serveWAFTransport.RoundTrip(question)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send the question: %w", err)
|
||||
}
|
||||
@@ -120,17 +119,17 @@ func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
||||
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
|
||||
}
|
||||
|
||||
return &discriminatorAnswer{res.StatusCode, res.Header, body}, nil
|
||||
return &servewafAnswer{res.StatusCode, res.Header, body}, nil
|
||||
}
|
||||
|
||||
// canBeAsked reports whether Go's client can send discriminator r as the visitor sent
|
||||
// canBeAsked reports whether Go's client can send servewaf r as the visitor sent
|
||||
// it. It cannot send these as they came:
|
||||
// - a target that does not start with /, or that starts with //;
|
||||
// - an empty Host, for which it sends discriminator's own address;
|
||||
// - an empty Host, for which it sends servewaf's own address;
|
||||
// - a Host holding an IPv6 zone, which it removes;
|
||||
// - more than one User-Agent, of which it sends the first;
|
||||
// - an empty User-Agent, which it leaves out, the one case that still goes to
|
||||
// discriminator.
|
||||
// servewaf.
|
||||
func canBeAsked(r *http.Request) bool {
|
||||
target, host := r.RequestURI, r.Host
|
||||
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
|
||||
@@ -170,13 +169,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
||||
question.TransferEncoding = in.TransferEncoding
|
||||
|
||||
question.Header = in.Header.Clone()
|
||||
question.Header.Set(discriminator.ClientHeader, rq.client.String())
|
||||
question.Header.Set(discriminator.PeerHeader, rq.peer.String())
|
||||
question.Header.Del(discriminator.ContentLengthHeader)
|
||||
question.Header.Set(servewaf.ClientHeader, rq.client.String())
|
||||
question.Header.Set(servewaf.PeerHeader, rq.peer.String())
|
||||
question.Header.Del(servewaf.ContentLengthHeader)
|
||||
|
||||
announced := in.Header.Get("Content-Length")
|
||||
if announced != "" {
|
||||
question.Header.Set(discriminator.ContentLengthHeader, announced)
|
||||
question.Header.Set(servewaf.ContentLengthHeader, announced)
|
||||
}
|
||||
|
||||
question.Header.Set(requestIDHeader, rq.id)
|
||||
@@ -191,13 +190,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
||||
return question, nil
|
||||
}
|
||||
|
||||
// answerWith gives the visitor discriminator's answer, less its disposition.
|
||||
func (rq *request) answerWith(answer *discriminatorAnswer) {
|
||||
// answerWith gives the visitor servewaf's answer, less its disposition.
|
||||
func (rq *request) answerWith(answer *servewafAnswer) {
|
||||
rq.prepareOwnAnswer(rq.requestDeadline())
|
||||
|
||||
header := rq.out.Header()
|
||||
maps.Copy(header, answer.header)
|
||||
header.Del(discriminator.DispositionHeader)
|
||||
header.Del(servewaf.DispositionHeader)
|
||||
|
||||
// Go's server adds a Content-Type it guesses from the body to a response whose
|
||||
// headers have no Content-Type key. The key with no value sends none.
|
||||
@@ -208,10 +207,10 @@ func (rq *request) answerWith(answer *discriminatorAnswer) {
|
||||
rq.send(answer.status, answer.body)
|
||||
}
|
||||
|
||||
// sendReport sends discriminator report on a goroutine of its own, so that the
|
||||
// sendReport sends servewaf report on a goroutine of its own, so that the
|
||||
// visitor does not wait for it. The report goes on after ctx, the request's own
|
||||
// context, has ended.
|
||||
func (h *handler) sendReport(ctx context.Context, report *discriminator.Report) {
|
||||
func (h *handler) sendReport(ctx context.Context, report *servewaf.Report) {
|
||||
h.reportsMu.Lock()
|
||||
defer h.reportsMu.Unlock()
|
||||
|
||||
@@ -227,9 +226,9 @@ func (h *handler) sendReport(ctx context.Context, report *discriminator.Report)
|
||||
})
|
||||
}
|
||||
|
||||
// postReport posts report to discriminator, and waits for its answer for up to
|
||||
// postReport posts report to servewaf, and waits for its answer for up to
|
||||
// SWRP_TIMEOUT_MS.
|
||||
func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error {
|
||||
func (h *handler) postReport(ctx context.Context, report *servewaf.Report) error {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode the report: %w", err)
|
||||
@@ -246,7 +245,7 @@ func (h *handler) postReport(ctx context.Context, report *discriminator.Report)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := h.discriminatorTransport.RoundTrip(req)
|
||||
res, err := h.serveWAFTransport.RoundTrip(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send the report: %w", err)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package rules reads the rule files: the plain text files in
|
||||
// SWW_RULES_DIR, one rule to a line, that each request is checked
|
||||
// SWWAF_RULES_DIR, one rule to a line, that each request is checked
|
||||
// against, as the "Rule files" section of SPEC.md describes. They are read
|
||||
// at start, and again once the directory has had no change for a short
|
||||
// time after one is edited, added or removed.
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
// extension ends the name of every rule file.
|
||||
const extension = ".rules"
|
||||
|
||||
// quietTime is how long SWW_RULES_DIR must go without a change before
|
||||
// quietTime is how long SWWAF_RULES_DIR must go without a change before
|
||||
// the rule files are read again, so that a file still being written, such
|
||||
// as one saved in place, appended to or copied in with scp, is read only
|
||||
// once whole.
|
||||
@@ -100,19 +100,19 @@ type Rule struct {
|
||||
|
||||
// Params are what Load needs.
|
||||
type Params struct {
|
||||
// Dir is the directory of the rule files (SWW_RULES_DIR).
|
||||
// Dir is the directory of the rule files (SWWAF_RULES_DIR).
|
||||
Dir string
|
||||
// Enabled is SWW_RULES_ENABLED: while it is false, no file is read
|
||||
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read
|
||||
// and no rule loaded.
|
||||
Enabled bool
|
||||
// ProcessLog receives how many rules were read, and the error in a
|
||||
// rule file edited while sww runs.
|
||||
// rule file edited while smallwebwaf runs.
|
||||
ProcessLog *slog.Logger
|
||||
// Alerts receive a file_error alert for that error.
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// Files are the rule files of a running sww, and the rules read
|
||||
// Files are the rule files of a running smallwebwaf, and the rules read
|
||||
// from them. They are safe for concurrent use.
|
||||
type Files struct {
|
||||
params Params
|
||||
@@ -271,7 +271,7 @@ func (f *Files) logRead(count int) {
|
||||
func read(dir string) ([]Rule, string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, dir, fmt.Errorf("SWW_RULES_DIR cannot be read: %w", err)
|
||||
return nil, dir, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err)
|
||||
}
|
||||
|
||||
var rules []Rule
|
||||
@@ -299,7 +299,7 @@ func read(dir string) ([]Rule, string, error) {
|
||||
// readFile appends the rules of the rule file at path to rules. places
|
||||
// are where each id read so far is, and gain those of the file.
|
||||
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR
|
||||
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||
|
||||
wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
|
||||
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
||||
": no such file or directory")
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
||||
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// SWW_RULES_DIR does not exist, which would stop the start.
|
||||
// SWWAF_RULES_DIR does not exist, which would stop the start.
|
||||
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||
params.Enabled = false
|
||||
|
||||
@@ -601,7 +601,7 @@ func wantRefused(t *testing.T, dir, want string) {
|
||||
}
|
||||
|
||||
// get returns a GET request for target, a path and an optional query, as
|
||||
// sww's server reads it, without a user agent.
|
||||
// smallwebwaf's server reads it, without a user agent.
|
||||
func get(t *testing.T, target string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
|
||||
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
|
||||
// The heuristic abuseipdb. It reads SWWAF_ABUSEIPDB_KEY,
|
||||
// SWWAF_ABUSEIPDB_MIN_SCORE and SWWAF_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.
|
||||
// abuseIPDB notes AbuseIPDB, as noteHit does, with the score, when its score
|
||||
// of the client is a hit, and refuses the request when SWWAF_REPUTATION_ACTION
|
||||
// is deny, as dnsbl does for a zone. While SWWAF_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 SWWAF_ABUSEIPDB_MIN_SCORE.
|
||||
type abuseIPDB struct{}
|
||||
|
||||
func (abuseIPDB) name() string { return "abuseipdb" }
|
||||
@@ -41,16 +39,15 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||
}
|
||||
|
||||
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")
|
||||
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
|
||||
|
||||
if rq.h.config.ReputationAction != deny {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionClientDenied,
|
||||
action: requestlog.ActionDenied,
|
||||
figures: map[string]any{
|
||||
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
|
||||
},
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -42,20 +42,20 @@ var (
|
||||
|
||||
// answerHealth answers a request for the health endpoint.
|
||||
func (rq *request) answerHealth() {
|
||||
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
// Set here rather than left to Go's server, which would set it only
|
||||
// after the log line has taken the response's headers.
|
||||
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(rq.out, "ok\n")
|
||||
}
|
||||
|
||||
// answerAdmin answers a request for one of discriminator's own endpoints. Each
|
||||
// answerAdmin answers a request for one of servewaf's own endpoints. Each
|
||||
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
|
||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is
|
||||
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||
// path does.
|
||||
func (rq *request) answerAdmin() {
|
||||
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
|
||||
token, answer := rq.endpoint()
|
||||
|
||||
@@ -250,7 +250,7 @@ type clientAnswer struct {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
// showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
|
||||
// showClient answers GET ClientsPath<ip> with what servewaf knows of the client:
|
||||
// its counters, its history, which holds its country as last looked up and its
|
||||
// offences, and its bans with their notes.
|
||||
func (rq *request) showClient() {
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
)
|
||||
|
||||
const (
|
||||
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
|
||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer
|
||||
// how a request carries it.
|
||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||
adminBearer = "Bearer " + adminSecret
|
||||
@@ -40,7 +40,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
// An empty token does not match the unset one either.
|
||||
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
||||
for _, e := range adminEndpoints() {
|
||||
s.ownEndpointRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
|
||||
s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||
for _, authorization := range []string{
|
||||
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
||||
} {
|
||||
got := s.ownEndpointRequest(authorization, e.method, e.path, e.body,
|
||||
got := s.adminRequest(authorization, e.method, e.path, e.body,
|
||||
http.StatusUnauthorized)
|
||||
if got.header.Get("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 /_sww/ is not found.
|
||||
// Any other request under /_smallwebwaf/ is not found.
|
||||
for _, e := range []adminEndpoint{
|
||||
{http.MethodPut, discriminator.BansPath, banOtherClient},
|
||||
{http.MethodDelete, discriminator.BansPath, ""},
|
||||
{http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodPost, discriminator.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
|
||||
{http.MethodPut, servewaf.BansPath, banOtherClient},
|
||||
{http.MethodDelete, servewaf.BansPath, ""},
|
||||
{http.MethodGet, servewaf.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodPost, servewaf.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""},
|
||||
} {
|
||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||
}
|
||||
@@ -118,10 +118,10 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
Reason: "probes for logins",
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
||||
http.StatusOK), want)
|
||||
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK),
|
||||
want)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
if line.BanExpires != requestlog.FormatTime(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.
|
||||
want.Notes.Requests, want.Notes.Refused = 1, 1
|
||||
|
||||
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
||||
|
||||
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||
// kept, marked lifted.
|
||||
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
lifted := clk.Now()
|
||||
want.Lifted = &lifted
|
||||
|
||||
wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
|
||||
wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "",
|
||||
http.StatusOK), want)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
||||
|
||||
// No ban on it is active any more.
|
||||
s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
@@ -181,8 +181,7 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
want.Expires = &expires
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
|
||||
want)
|
||||
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, body, http.StatusOK), want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,8 +250,7 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
||||
"request body too large",
|
||||
},
|
||||
} {
|
||||
got := s.admin(http.MethodPost, discriminator.BansPath, tc.body,
|
||||
http.StatusBadRequest)
|
||||
got := s.admin(http.MethodPost, servewaf.BansPath, tc.body, http.StatusBadRequest)
|
||||
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)
|
||||
}
|
||||
@@ -276,7 +274,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
// 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.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
// Asked about by its address in IPv6 form too.
|
||||
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
||||
@@ -285,8 +283,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
|
||||
http.StatusOK), &got)
|
||||
decode(t, s.admin(http.MethodGet, servewaf.ClientsPath+addr, "", http.StatusOK), &got)
|
||||
|
||||
if got.Client == nil {
|
||||
t.Fatalf("%s: no client", addr)
|
||||
@@ -308,15 +305,13 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
|
||||
// Of an address no request came from and no ban covers, nothing is
|
||||
// known.
|
||||
got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "",
|
||||
http.StatusOK)
|
||||
got := s.admin(http.MethodGet, servewaf.ClientsPath+"198.51.100.99", "", http.StatusOK)
|
||||
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
||||
t.Errorf("an unknown client is answered\n%s", got.body)
|
||||
}
|
||||
|
||||
s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "",
|
||||
http.StatusBadRequest)
|
||||
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
|
||||
s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
||||
s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "",
|
||||
http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -329,7 +324,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
||||
})
|
||||
|
||||
for _, e := range adminEndpoints() {
|
||||
s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
||||
s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
@@ -337,40 +332,40 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
|
||||
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens.
|
||||
type adminEndpoint struct {
|
||||
method, path, body string
|
||||
}
|
||||
|
||||
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
|
||||
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN
|
||||
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||
// bans on otherClient, and asking about otherClient.
|
||||
func adminEndpoints() []adminEndpoint {
|
||||
return []adminEndpoint{
|
||||
{http.MethodGet, discriminator.BansPath, ""},
|
||||
{http.MethodPost, discriminator.BansPath, banOtherClient},
|
||||
{http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodGet, discriminator.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, servewaf.BansPath, ""},
|
||||
{http.MethodPost, servewaf.BansPath, banOtherClient},
|
||||
{http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodGet, servewaf.ClientsPath + otherClient, ""},
|
||||
}
|
||||
}
|
||||
|
||||
// admin sends a request with method for path, with body, to discriminator's own
|
||||
// admin sends a request with method for path, with body, to servewaf's own
|
||||
// address, with the admin token, and checks that it is answered with status, its
|
||||
// log line's action own_endpoint. It returns the answer.
|
||||
// log line's action admin. It returns the answer.
|
||||
func (s *sender) admin(method, path, body string, status int) answer {
|
||||
s.t.Helper()
|
||||
|
||||
return s.ownEndpointRequest(adminBearer, method, path, body, status)
|
||||
return s.adminRequest(adminBearer, method, path, body, status)
|
||||
}
|
||||
|
||||
// ownEndpointRequest is admin with authorization as the request's Authorization
|
||||
// header unless it is "".
|
||||
func (s *sender) ownEndpointRequest(
|
||||
// adminRequest is admin with authorization as the request's Authorization header
|
||||
// unless it is "".
|
||||
func (s *sender) adminRequest(
|
||||
authorization, method, path, body string, status int,
|
||||
) answer {
|
||||
s.t.Helper()
|
||||
|
||||
req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
|
||||
req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body))
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
@@ -380,7 +375,7 @@ func (s *sender) ownEndpointRequest(
|
||||
|
||||
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||
s.sent++
|
||||
wantLine(s.t, line, status, requestlog.ActionOwnEndpoint)
|
||||
wantLine(s.t, line, status, requestlog.ActionAdmin)
|
||||
|
||||
return got
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||
// alertInstance is the instance every alert of these tests gives.
|
||||
alertInstance = "fsn1app1/blog"
|
||||
alertInstance = "fsn1app1/gitea"
|
||||
)
|
||||
|
||||
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
||||
@@ -31,14 +31,14 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
||||
start := clk.Now()
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ban := server.Ledger.Bans(netblock)[0]
|
||||
|
||||
// A request refused under the ban raises no other alert.
|
||||
clk.advance(time.Minute)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||
Netblock: netblock, Cause: bans.CauseLimit,
|
||||
@@ -62,24 +62,22 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
|
||||
|
||||
// The probe bans the client for seven days, and its next request makes
|
||||
// the ban permanent. The request after that changes nothing.
|
||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
attackBan := server.Ledger.Bans(netblock)[0]
|
||||
|
||||
clk.advance(time.Minute)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
permanentBan := server.Ledger.Bans(netblock)[0]
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
// Another client's probe after its first ban has run out without a
|
||||
// request makes a permanent ban at once.
|
||||
s.request(otherClient, "/.env", http.StatusForbidden,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||
clk.advance(7 * 24 * time.Hour)
|
||||
s.request(otherClient, "/.env", http.StatusForbidden,
|
||||
requestlog.ActionPermanentlyBanned)
|
||||
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
otherBans := server.Ledger.Bans(other)
|
||||
|
||||
@@ -203,7 +201,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
||||
// never sent them, and returns the queue they wait in as well.
|
||||
func startWithAlerts(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||
@@ -212,21 +210,21 @@ func startWithAlerts(
|
||||
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||
func startAppWithAlerts(
|
||||
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, handler)
|
||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||
settings := map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
alertWebhookURL: "https://alerts.example/sww",
|
||||
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
||||
instanceName: alertInstance,
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
||||
|
||||
s := &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}
|
||||
s := &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}
|
||||
|
||||
return s, clk, server, queue
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -13,19 +13,19 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"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
|
||||
// count.
|
||||
const (
|
||||
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWW_ANOMALY_NET_"
|
||||
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||
anomalyWatch = "SWW_WATCH_"
|
||||
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWWAF_ANOMALY_NET_"
|
||||
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
||||
anomalyWatch = "SWWAF_WATCH_"
|
||||
|
||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||
@@ -35,9 +35,9 @@ const (
|
||||
|
||||
// The other anomaly settings.
|
||||
const (
|
||||
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWW_WATCH_NETS"
|
||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWWAF_WATCH_NETS"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -73,7 +73,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
|
||||
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
|
||||
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
|
||||
{
|
||||
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
|
||||
anomalyWatch, anomaly.ScopeWatch, netip.MustParsePrefix(clientsNet),
|
||||
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
|
||||
},
|
||||
} {
|
||||
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||
denied = "192.0.2.20" // in SWW_DENY_NETS
|
||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
denied = "192.0.2.20" // in SWWAF_DENY_NETS
|
||||
)
|
||||
|
||||
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||
@@ -187,7 +187,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
||||
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
||||
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
||||
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
||||
{denied, "/", http.StatusForbidden, requestlog.ActionClientDenied},
|
||||
{denied, "/", http.StatusForbidden, requestlog.ActionDenied},
|
||||
} {
|
||||
for range 3 {
|
||||
s.request(sent.from, sent.path, sent.status, sent.action)
|
||||
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
||||
// kept.
|
||||
geojsURL, _, release := startHeldGeoJS(t)
|
||||
|
||||
var server atomic.Pointer[discriminator.Server]
|
||||
var server atomic.Pointer[servewaf.Server]
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
release()
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,29 +15,29 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||||
// discriminator has to answer.
|
||||
// servewaf has to answer.
|
||||
answerTimeLimit = 2 * time.Second
|
||||
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
|
||||
// defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT.
|
||||
defaultLookupWait = 800 * time.Millisecond
|
||||
// The header limits SPEC.md gives.
|
||||
cookieLimit = 50
|
||||
headerFieldLimit = 100
|
||||
// cookieHeader is the header the cookie limit counts.
|
||||
cookieHeader = "Cookie"
|
||||
// tooLarge is how discriminator refuses a request over the header limits.
|
||||
// tooLarge is how servewaf refuses a request over the header limits.
|
||||
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
||||
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
||||
// that silentGeoJSURL names too.
|
||||
silentAbuseIPDBURL = "silent://abuseipdb/api/v2/check"
|
||||
// that unansweredGeoJSURL names too.
|
||||
silentAbuseIPDBURL = "unanswered://abuseipdb/api/v2/check"
|
||||
// wholeLimit is the percentage of a rate limit that lowers it not at all.
|
||||
wholeLimit = 100
|
||||
// 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
|
||||
// fields: all but ClientHeader and PeerHeader, which discriminator removes. Go's
|
||||
// fields: all but ClientHeader and PeerHeader, which servewaf removes. Go's
|
||||
// client sends nothing else but Host, which the limit does not count, once
|
||||
// User-Agent is set.
|
||||
func fillHeaders(req *http.Request, total int) {
|
||||
@@ -70,7 +70,7 @@ func fillHeaders(req *http.Request, total int) {
|
||||
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
@@ -81,20 +81,20 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||
}
|
||||
|
||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
if line.DurationCoreRuleSetMS != nil {
|
||||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS)
|
||||
if line.DurationWAF != nil {
|
||||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", nil)
|
||||
s := startServeWAF(t, "", nil)
|
||||
req := s.question(siteA, client, "/")
|
||||
fillHeaders(req, headerFieldLimit+1)
|
||||
do(t, req)
|
||||
@@ -148,7 +148,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
tc.make(req)
|
||||
|
||||
@@ -156,16 +156,16 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
line := s.out.requestLine(t)
|
||||
|
||||
if tc.refused {
|
||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if line.DurationCoreRuleSetMS == nil {
|
||||
if line.DurationWAF == nil {
|
||||
t.Error("the Core Rule Set did not read a request within the limits")
|
||||
}
|
||||
})
|
||||
@@ -178,16 +178,16 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||||
for _, env := range []map[string]string{
|
||||
{wafMode: off},
|
||||
{wafMode: "detect"},
|
||||
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
|
||||
{wafMode: block, "SWWAF_WAF_EXEMPT_PATHS": "/"},
|
||||
} {
|
||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", env)
|
||||
s := startServeWAF(t, "", env)
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -195,17 +195,17 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||||
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client})
|
||||
s := startServeWAF(t, "", map[string]string{wafMode: block, allowNets: client})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
|
||||
s := startServeWAF(t, "", map[string]string{mode: observe, wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
@@ -216,20 +216,19 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||
}
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
|
||||
line.DurationCoreRuleSetMS != nil {
|
||||
t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none",
|
||||
line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge)
|
||||
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
|
||||
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
||||
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
ask := func() answer {
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
@@ -237,11 +236,11 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
return do(t, req)
|
||||
}
|
||||
|
||||
wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny)
|
||||
|
||||
lines := s.out.requestLines(t, 2)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
}
|
||||
|
||||
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||
@@ -250,19 +249,19 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, path, action string
|
||||
}{
|
||||
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
|
||||
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
|
||||
{"a trap path", "/wp-login.php", requestlog.ActionBanned},
|
||||
{"a block rule", "/blocked", requestlog.ActionRuleBlocked},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{
|
||||
s := startServeWAF(t, "", map[string]string{
|
||||
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
req := s.question(siteA, client, tc.path)
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
||||
})
|
||||
}
|
||||
@@ -272,30 +271,30 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||
s := startServeWAF(t, geojsURL, map[string]string{
|
||||
unknownLimitPercent: "0", lookupTimeout: "1h",
|
||||
})
|
||||
req := s.question(siteA, fromDE, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
if line.RateLimitPercent != nil {
|
||||
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
|
||||
if line.LimitPercent != nil {
|
||||
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
||||
}
|
||||
|
||||
// The client is under no ban, and GeoJS places it in a country.
|
||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, servewaf.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||
s := startServeWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||
s.server.GeoJS.Load([]lookup.Answer{{
|
||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||||
@@ -304,23 +303,22 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
||||
requestlog.ActionClientIsRateLimited)
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
}
|
||||
|
||||
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||
s := startServeWAF(t, geojsURL, map[string]string{
|
||||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
||||
})
|
||||
req := s.question(siteA, fromDE, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
got := do(t, req)
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
|
||||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||||
if asn != asnDE || country != "DE" {
|
||||
@@ -329,18 +327,18 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newDiscriminatorInBubble makes a discriminator for a synctest bubble: GeoJS never
|
||||
// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never
|
||||
// answers, a setting needs its answer, and everything is at its default but
|
||||
// for env. No request to it may use the network.
|
||||
func newDiscriminatorInBubble(
|
||||
func newServeWAFInBubble(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*discriminator.Server, *output) {
|
||||
) (*servewaf.Server, *output) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{tagDeny: "country=KP"}
|
||||
settings := map[string]string{deniedCountries: "KP"}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings)
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings)
|
||||
|
||||
return server, out
|
||||
}
|
||||
@@ -348,13 +346,13 @@ func newDiscriminatorInBubble(
|
||||
// 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.
|
||||
func askDirectly(
|
||||
t *testing.T, server *discriminator.Server, change func(*http.Request),
|
||||
t *testing.T, server *servewaf.Server, change func(*http.Request),
|
||||
) (int, time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
||||
req.Header.Set(discriminator.ClientHeader, client)
|
||||
req.Header.Set(servewaf.ClientHeader, client)
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
change(req)
|
||||
|
||||
@@ -367,17 +365,17 @@ func askDirectly(
|
||||
}
|
||||
|
||||
// banClient bans client at now.
|
||||
func banClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||
func banClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
||||
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
||||
}
|
||||
|
||||
// countClient counts one request from client at now for the rate limits.
|
||||
func countClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||
func countClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
||||
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
||||
}
|
||||
|
||||
// listClient puts client on the blocklist at dropURL.
|
||||
func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||
func listClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
||||
t.Helper()
|
||||
|
||||
err := server.Lists.Load([]reputation.List{{
|
||||
@@ -389,13 +387,13 @@ func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||
}
|
||||
|
||||
// decideOnClient puts client on the CrowdSec decision list.
|
||||
func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||
func decideOnClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
||||
t.Helper()
|
||||
loadDecisions(t, server, now, clientsDecision)
|
||||
}
|
||||
|
||||
// leaveAsItIs prepares nothing.
|
||||
func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
|
||||
func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {}
|
||||
|
||||
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -406,45 +404,41 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
// prepare gets the server ready, at now, before the question.
|
||||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
||||
// cookie is the request's Cookie header.
|
||||
cookie string
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
{
|
||||
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
|
||||
name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client},
|
||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
||||
},
|
||||
{
|
||||
name: "a ban",
|
||||
prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
|
||||
prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
|
||||
},
|
||||
{
|
||||
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
||||
prepare: countClient, status: forbidden,
|
||||
action: requestlog.ActionClientIsRateLimited,
|
||||
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||
},
|
||||
{
|
||||
name: "the rate limits, with a biased threshold",
|
||||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||||
prepare: countClient, status: forbidden,
|
||||
action: requestlog.ActionClientIsRateLimited,
|
||||
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||
},
|
||||
{
|
||||
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
||||
prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
|
||||
prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
|
||||
},
|
||||
{
|
||||
name: "the CrowdSec decision list",
|
||||
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
||||
prepare: decideOnClient, status: forbidden,
|
||||
action: requestlog.ActionTemporarilyBanned,
|
||||
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
|
||||
},
|
||||
{
|
||||
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
||||
prepare: leaveAsItIs, status: tooLarge,
|
||||
action: requestlog.ActionRequestHeaderTooLarge,
|
||||
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -453,7 +447,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, so that a request that does not wait takes no time at all.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||
server, out := newServeWAFInBubble(t, tc.env)
|
||||
tc.prepare(t, server, time.Now())
|
||||
|
||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||
@@ -481,7 +475,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
// prepare gets the server ready, at now, before the question.
|
||||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
@@ -510,7 +504,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||
server, out := newServeWAFInBubble(t, tc.env)
|
||||
tc.prepare(t, server, time.Now())
|
||||
|
||||
status, took := askDirectly(t, server, func(*http.Request) {})
|
||||
@@ -526,9 +520,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
t *testing.T,
|
||||
) {
|
||||
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
held, askedGeoJS, _ := startHeldGeoJS(t)
|
||||
@@ -541,14 +533,12 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
|
||||
t.Cleanup(func() { _ = resolver.Close() })
|
||||
|
||||
server, out, _ := newDiscriminatorWith(t, func(
|
||||
params discriminator.Params,
|
||||
) *discriminator.Server {
|
||||
server, out, _ := newServeWAFWith(t, func(params servewaf.Params) *servewaf.Server {
|
||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||
|
||||
return discriminator.New(params)
|
||||
return servewaf.New(params)
|
||||
}, held, time.Now, map[string]string{
|
||||
tagDeny: "country=KP", wafMode: block,
|
||||
deniedCountries: "KP", wafMode: block,
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||
abuseIPDBKey: accountKey,
|
||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||
@@ -1,7 +1,7 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
// 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.
|
||||
// heuristics share. active_ban reads SWWAF_MODE; the bans are the ban ledger's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
|
||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
||||
// with action.
|
||||
func (rq *request) banResponse(action string) *refusal {
|
||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||
@@ -50,34 +50,22 @@ func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: banAction(ban.Permanent()),
|
||||
action: requestlog.ActionBanned,
|
||||
figures: map[string]any{
|
||||
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
||||
"netblock": ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// banAction is the action of a request refused under a ban or for making one:
|
||||
// permanent says the ban never runs out.
|
||||
func banAction(permanent bool) string {
|
||||
if permanent {
|
||||
return requestlog.ActionPermanentlyBanned
|
||||
}
|
||||
|
||||
return requestlog.ActionTemporarilyBanned
|
||||
}
|
||||
|
||||
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
||||
// window, the count and the limit.
|
||||
func limitFigures(hit ratelimit.Hit) map[string]any {
|
||||
return map[string]any{
|
||||
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
|
||||
}
|
||||
return map[string]any{"window": hit.Window, "count": hit.Count, figureLimit: hit.Limit}
|
||||
}
|
||||
|
||||
// countedBytes returns the request's bytes, once it has ended, as the
|
||||
// byte limits and the anomaly thresholds count them: the response's body
|
||||
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
|
||||
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says.
|
||||
func (rq *request) countedBytes() int64 {
|
||||
response, request := rq.out.bytes, rq.requestBytes
|
||||
|
||||
@@ -94,7 +82,7 @@ func (rq *request) countedBytes() int64 {
|
||||
// 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
|
||||
// the metrics. status is what the client was sent, or is sent:
|
||||
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||
// SWWAF_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
|
||||
// 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
|
||||
@@ -106,7 +94,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||
case ratelimit.KindBytes:
|
||||
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
||||
case ratelimit.KindRefusals:
|
||||
rq.line.LimitHit = requestlog.LimitHitMinuteRefusals
|
||||
rq.line.LimitHit = requestlog.LimitHitErrorBurst
|
||||
default:
|
||||
rq.line.LimitHit = hit.Window
|
||||
}
|
||||
@@ -134,9 +122,9 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||
|
||||
switch hit.Kind {
|
||||
case ratelimit.KindRequests:
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.rateLimitPercent.logged()
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged()
|
||||
case ratelimit.KindBytes:
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesLimitPercent.logged()
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged()
|
||||
}
|
||||
|
||||
if rq.h.config.Observe {
|
||||
@@ -161,11 +149,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
|
||||
// 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
|
||||
// alert would be sent. It returns the action for that ban.
|
||||
func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
||||
// alert would be sent.
|
||||
func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
||||
netblock := rq.h.netblock(rq.client)
|
||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
|
||||
return
|
||||
}
|
||||
|
||||
notes.ASN = rq.line.ASN
|
||||
@@ -181,7 +169,7 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
return
|
||||
}
|
||||
|
||||
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
||||
@@ -190,18 +178,16 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
||||
if made {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
}
|
||||
|
||||
// banForCrowdSec bans the client's netblock at now until decision,
|
||||
// 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
|
||||
// would be sent. It returns the action for that ban.
|
||||
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string {
|
||||
// would be sent.
|
||||
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
||||
netblock := rq.h.netblock(rq.client)
|
||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
|
||||
return
|
||||
}
|
||||
|
||||
notes := bans.Notes{
|
||||
@@ -220,7 +206,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
return
|
||||
}
|
||||
|
||||
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
||||
@@ -230,14 +216,12 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
|
||||
if made {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
}
|
||||
|
||||
// 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
|
||||
// have made is worked out only then, at most once per
|
||||
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||
// netblock's requests, which can mean going through every client.
|
||||
func (rq *request) wouldAlertBan(
|
||||
netblock netip.Prefix, now time.Time, cause string,
|
||||
@@ -303,7 +287,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
||||
}
|
||||
|
||||
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||
// counts it in.
|
||||
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||
addr := client.Unmap()
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
userAgent = "ban-test/1.0"
|
||||
// permanent is the log line's ban_expires for a permanent ban.
|
||||
permanent = "permanent"
|
||||
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
|
||||
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection.
|
||||
banResponseClose = "close"
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
|
||||
// client for an hour, the default.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||
line.BanExpires != expires {
|
||||
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.
|
||||
clk.advance(time.Hour - time.Second)
|
||||
|
||||
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
|
||||
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,
|
||||
@@ -78,9 +78,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
breakLimit := func() string {
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
return line.BanExpires
|
||||
return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires
|
||||
}
|
||||
wantExpires := func(got string, length time.Duration) {
|
||||
t.Helper()
|
||||
@@ -91,19 +89,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
|
||||
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||
// A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within
|
||||
// SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||
wantExpires(breakLimit(), 10*time.Minute)
|
||||
clk.advance(10*time.Minute + time.Hour)
|
||||
wantExpires(breakLimit(), 30*time.Minute)
|
||||
|
||||
// Later than that, SWW_LIMIT_BAN_DURATION again.
|
||||
// Later than that, SWWAF_LIMIT_BAN_DURATION again.
|
||||
clk.advance(30*time.Minute + time.Hour + time.Second)
|
||||
wantExpires(breakLimit(), 10*time.Minute)
|
||||
clk.advance(10 * time.Minute)
|
||||
wantExpires(breakLimit(), 30*time.Minute)
|
||||
|
||||
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
|
||||
// 90 minutes would be longer than SWWAF_MAX_BAN_DURATION: the ban is
|
||||
// permanent.
|
||||
clk.advance(30 * time.Minute)
|
||||
|
||||
@@ -113,7 +111,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
}
|
||||
|
||||
clk.advance(365 * 24 * time.Hour)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||
@@ -125,10 +123,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||
// client for an hour.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
for range 3 {
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
// Later the same day the client has its whole allowance again: the
|
||||
@@ -137,7 +135,7 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||
clk.advance(time.Hour)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
||||
@@ -150,8 +148,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
|
||||
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
||||
const (
|
||||
allowed = "203.0.113.60" // in SWW_ALLOW_NETS
|
||||
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||
allowed = "203.0.113.60" // in SWWAF_ALLOW_NETS
|
||||
exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -166,7 +164,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
nil, []string{otherClient, exempt},
|
||||
},
|
||||
{
|
||||
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
|
||||
"the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets",
|
||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||
},
|
||||
@@ -175,7 +173,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||
},
|
||||
{
|
||||
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
|
||||
"the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets",
|
||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
||||
},
|
||||
@@ -192,10 +190,10 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
s, _, _ := startWithClock(t, "", env)
|
||||
|
||||
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
for _, sent := range tc.refused {
|
||||
s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(sent, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
for _, sent := range tc.let {
|
||||
@@ -213,14 +211,14 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
banScopeV4Prefix: "24",
|
||||
tagDeny: denyKP,
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
|
||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||
if line.Country != "" {
|
||||
t.Errorf("log line has country %q, want none", line.Country)
|
||||
}
|
||||
@@ -239,9 +237,9 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
|
||||
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||
wantHeuristic(t, line, "active_ban",
|
||||
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
|
||||
}
|
||||
@@ -249,10 +247,10 @@ func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
|
||||
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting string // "" leaves SWW_BAN_RESPONSE at its default
|
||||
setting string // "" leaves SWWAF_BAN_RESPONSE at its default
|
||||
status int // 0 is the connection closed without an answer
|
||||
}{
|
||||
{"", http.StatusForbidden},
|
||||
@@ -268,7 +266,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
tagDeny: denyKP,
|
||||
deniedCountries: "kp",
|
||||
}
|
||||
|
||||
if tc.setting != "" {
|
||||
@@ -277,11 +275,11 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||
|
||||
s, _, _ := startWithClock(t, geojsURL, env)
|
||||
|
||||
s.get(denied, tc.status, requestlog.ActionClientDenied)
|
||||
s.get(fromKP, tc.status, requestlog.ActionClientDenied)
|
||||
s.get(denied, tc.status, requestlog.ActionDenied)
|
||||
s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited)
|
||||
s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned)
|
||||
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
||||
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -293,15 +291,15 @@ func TestBanNotes(t *testing.T) {
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
tagDeny: denyKP,
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.request(fromDE, "/repo/commits?page=2",
|
||||
http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||
want := bans.Ban{
|
||||
@@ -344,7 +342,7 @@ func TestBanNotes(t *testing.T) {
|
||||
// The next ban counts this one among the earlier.
|
||||
clk.advance(time.Hour)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
got = ledger.Bans(netblock)
|
||||
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
@@ -362,15 +360,15 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||
|
||||
// One ban is held, so otherClient's ban drops client's.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
// clock is the time a test sets, by which sww counts requests and
|
||||
// clock is the time a test sets, by which smallwebwaf counts requests and
|
||||
// makes bans.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
@@ -393,14 +391,14 @@ func (c *clock) advance(d time.Duration) {
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// startWithClock starts discriminator, and rproxy in front of an app that answers 200,
|
||||
// startWithClock starts servewaf, and rproxy in front of an app that answers 200,
|
||||
// with the settings in env on top of trusting localhost's
|
||||
// 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
|
||||
// window.
|
||||
func startWithClock(
|
||||
t *testing.T, geojsURL string, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server) {
|
||||
) (*sender, *clock, *servewaf.Server) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
@@ -410,20 +408,19 @@ func startWithClock(
|
||||
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||
|
||||
return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server
|
||||
return &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}, clk, server
|
||||
}
|
||||
|
||||
// sender sends requests to rproxy one after another, each on a connection of its
|
||||
// own, and checks each one's answer and log line. discriminatorAddr is where
|
||||
// discriminator's own endpoints are, which ownEndpointRequest asks. Every request
|
||||
// discriminator gets must come through the sender, since the log lines are
|
||||
// matched to them in order.
|
||||
// own, and checks each one's answer and log line. serveWAFAddr is where servewaf's
|
||||
// own endpoints are, which adminRequest asks. Every request servewaf gets must
|
||||
// come through the sender, since the log lines are matched to them in order.
|
||||
type sender struct {
|
||||
t *testing.T
|
||||
addr string
|
||||
discriminatorAddr string
|
||||
out *output
|
||||
sent int
|
||||
t *testing.T
|
||||
addr string
|
||||
serveWAFAddr string
|
||||
out *output
|
||||
sent int
|
||||
}
|
||||
|
||||
// get sends a GET request for / from the client at from.
|
||||
@@ -0,0 +1,119 @@
|
||||
package servewaf
|
||||
|
||||
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: one of its lists is not empty,
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_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.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
||||
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 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 SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
||||
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
||||
// country, unless its request skipped the wait for GeoJS,
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
|
||||
// percentage of SWWAF_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 SWWAF_REPUTATION_ACTION while it is limit. For the byte
|
||||
// limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_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, "SWWAF_UNKNOWN_LIMIT_PERCENT"}
|
||||
}
|
||||
|
||||
fetched := percentage{percent: whole}
|
||||
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||
fetched = percentage{percent, "SWWAF_ASN_LIMIT_PERCENT_URL"}
|
||||
}
|
||||
|
||||
blocklisted := percentage{percent: whole}
|
||||
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
|
||||
}
|
||||
|
||||
reputationListed := percentage{percent: whole}
|
||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
||||
}
|
||||
|
||||
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
|
||||
fetched)
|
||||
countryRequests := given(cfg.CountryLimitPercent, country,
|
||||
"SWWAF_COUNTRY_LIMIT_PERCENT")
|
||||
|
||||
asnBytes, countryBytes := asnRequests, countryRequests
|
||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||
asnBytes = given(cfg.ASNBytesPercent, asn, "SWWAF_ASN_BYTES_PERCENT")
|
||||
}
|
||||
|
||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
|
||||
}
|
||||
|
||||
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
||||
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
|
||||
}
|
||||
|
||||
// given returns the percentage percents, the setting named setting, gives
|
||||
// code, an AS number or a country, 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 discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,17 +15,18 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// The biased thresholds.
|
||||
const (
|
||||
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,11 +35,6 @@ const (
|
||||
asnDEHalf = asnDE + ":50"
|
||||
asnDEQuarter = asnDE + ":25"
|
||||
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 = "192.0.2.80"
|
||||
// fourAMinute is the rate limit these tests set: half of it is 2
|
||||
@@ -58,8 +54,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
setting, value, from string
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||
{tagLimitPercent, countryTagDEHalf, fromDE},
|
||||
{asnLimitPercent, asnDEHalf, fromDE},
|
||||
{countryLimitPercent, countryDEHalf, fromDE},
|
||||
{unknownLimitPercent, "50", unplaced},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
@@ -76,19 +72,17 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
||||
}{
|
||||
{http.StatusOK, requestlog.ActionForward},
|
||||
{http.StatusOK, requestlog.ActionForward},
|
||||
{http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
||||
{http.StatusForbidden, requestlog.ActionRateLimited},
|
||||
} {
|
||||
line := s.get(tc.from, sent.status, sent.action)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
"50 from "+tc.setting)
|
||||
}
|
||||
|
||||
// fromKP, which no setting lists, has the whole limit.
|
||||
for range 3 {
|
||||
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
none)
|
||||
}
|
||||
})
|
||||
@@ -102,8 +96,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
setting, value, from string
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||
{tagLimitPercent, "Country=de:50", fromDE},
|
||||
{asnLimitPercent, asnDEHalf, fromDE},
|
||||
{countryLimitPercent, "DE:50", fromDE},
|
||||
{unknownLimitPercent, "50", unplaced},
|
||||
{asnBytesPercent, "as64496:50", fromDE},
|
||||
{countryBytesPercent, countryDEHalf, fromDE},
|
||||
@@ -121,8 +115,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||
}
|
||||
|
||||
wantPercent(t, "bytes_limit_percent",
|
||||
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||
"50 from "+tc.setting)
|
||||
|
||||
// fromKP, which no setting lists, has the whole limit.
|
||||
@@ -131,8 +124,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
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)
|
||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -143,9 +135,9 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
// rateLimitPercent and bytesLimitPercent are the log line's, as
|
||||
// percentText gives them, and limitHit is its limit_hit.
|
||||
rateLimitPercent, bytesLimitPercent, limitHit string
|
||||
// limitPercent and bytesPercent are the log line's, as percentText
|
||||
// gives them, and limitHit is its limit_hit.
|
||||
limitPercent, bytesPercent, limitHit string
|
||||
}{
|
||||
{
|
||||
"lowering the byte limits alone",
|
||||
@@ -159,13 +151,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
||||
},
|
||||
{
|
||||
"raising the byte limits back",
|
||||
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||
"50 from " + tagLimitPercent, none, "",
|
||||
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||
"50 from " + asnLimitPercent, none, "",
|
||||
},
|
||||
{
|
||||
"raising the byte limits back, by country",
|
||||
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
||||
"50 from " + tagLimitPercent, none, "",
|
||||
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
|
||||
"50 from " + countryLimitPercent, none, "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -181,12 +173,10 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
||||
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
|
||||
}
|
||||
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
tc.rateLimitPercent)
|
||||
wantPercent(t, "bytes_limit_percent",
|
||||
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||
tc.bytesLimitPercent)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
tc.limitPercent)
|
||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||
tc.bytesPercent)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -194,19 +184,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
||||
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
|
||||
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
|
||||
|
||||
// The first request breaks the limit, and bans the client; the log line
|
||||
// gives the 0.
|
||||
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
if line.fields["rate_limit_percent"] != float64(0) ||
|
||||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
|
||||
t.Errorf("log line has rate_limit_percent %v from %v, want 0 from %s",
|
||||
line.fields["rate_limit_percent"], line.fields["rate_limit_percent_setting"],
|
||||
unknownLimitPercent)
|
||||
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
if line.fields["limit_percent"] != float64(0) ||
|
||||
line.fields["limit_percent_setting"] != asnLimitPercent {
|
||||
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
||||
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
||||
asnLimitPercent)
|
||||
}
|
||||
|
||||
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestLowestPercentageApplies(t *testing.T) {
|
||||
@@ -216,34 +206,34 @@ func TestLowestPercentageApplies(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
from string
|
||||
// want is the log line's rate_limit_percent, as percentText gives it.
|
||||
// want is the log line's limit_percent, as percentText gives it.
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"the country's",
|
||||
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
|
||||
fromDE, "25 from " + tagLimitPercent,
|
||||
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
|
||||
fromDE, "25 from " + countryLimitPercent,
|
||||
},
|
||||
{
|
||||
"the AS number's",
|
||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
|
||||
fromDE, "25 from " + tagLimitPercent,
|
||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
|
||||
fromDE, "25 from " + asnLimitPercent,
|
||||
},
|
||||
{
|
||||
"the AS number's, the first of two alike",
|
||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
|
||||
fromDE, "25 from " + tagLimitPercent,
|
||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
|
||||
fromDE, "25 from " + asnLimitPercent,
|
||||
},
|
||||
{
|
||||
"that for a client without a country",
|
||||
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
||||
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
|
||||
noCountry, "25 from " + unknownLimitPercent,
|
||||
},
|
||||
{
|
||||
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||
"the AS number's, for a client without a country",
|
||||
map[string]string{tagLimitPercent: "asn=64500:25"},
|
||||
noCountry, "25 from " + tagLimitPercent,
|
||||
map[string]string{asnLimitPercent: "AS64500:25"},
|
||||
noCountry, "25 from " + asnLimitPercent,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -256,9 +246,8 @@ func TestLowestPercentageApplies(t *testing.T) {
|
||||
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
tc.want)
|
||||
})
|
||||
}
|
||||
@@ -276,7 +265,7 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test
|
||||
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.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
}
|
||||
|
||||
// One in a country has the whole limit.
|
||||
@@ -291,7 +280,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||
map[string]string{unknownLimitPercent: "0"})
|
||||
|
||||
// Once the time the request waits for its answer is up, the client
|
||||
@@ -300,9 +289,8 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
"0 from "+unknownLimitPercent)
|
||||
})
|
||||
}
|
||||
@@ -316,12 +304,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
setting, value string
|
||||
waits bool
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, true},
|
||||
{tagLimitPercent, countryTagDEHalf, true},
|
||||
{tagDeny, denyKP, true},
|
||||
// A tag the lookup does not give needs no answer.
|
||||
{tagLimitPercent, "vpn:50", false},
|
||||
{tagDeny, "vpn", false},
|
||||
{asnLimitPercent, asnDEHalf, true},
|
||||
{countryLimitPercent, countryDEHalf, true},
|
||||
{asnBytesPercent, asnDEHalf, true},
|
||||
{countryBytesPercent, countryDEHalf, true},
|
||||
{unknownLimitPercent, "99", true},
|
||||
@@ -335,10 +319,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{
|
||||
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
||||
})
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
|
||||
began := time.Now()
|
||||
|
||||
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
||||
@@ -381,15 +363,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
||||
{
|
||||
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
||||
"a rate limit",
|
||||
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
|
||||
3, http.StatusForbidden, requestlog.ActionClientIsRateLimited,
|
||||
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
|
||||
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
|
||||
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
||||
"requests per minute over the limit of 3", "25 from " + asnLimitPercent,
|
||||
},
|
||||
{
|
||||
// The byte limits' percentage, not the rate limits'.
|
||||
"a byte limit",
|
||||
map[string]string{
|
||||
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
||||
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
|
||||
asnBytesPercent: asnDEHalf,
|
||||
},
|
||||
0, http.StatusOK, requestlog.ActionForward,
|
||||
@@ -437,7 +419,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
||||
// clock.
|
||||
func startWithLookups(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *discriminator.Server, *alerts.Queue) {
|
||||
) (*sender, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||
@@ -452,7 +434,7 @@ func startWithLookups(
|
||||
// country, and no other address.
|
||||
func startWithLookupsAndClock(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||
@@ -481,13 +463,11 @@ func (s *sender) uploadFrom(from string) logLine {
|
||||
// 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
|
||||
// answered.
|
||||
func serveFromDE(
|
||||
t *testing.T, server *discriminator.Server, method string, body io.Reader,
|
||||
) {
|
||||
func serveFromDE(t *testing.T, server *servewaf.Server, method string, body io.Reader) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||
req.Header.Set(discriminator.ClientHeader, fromDE)
|
||||
req.Header.Set(servewaf.ClientHeader, fromDE)
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
@@ -505,7 +485,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
|
||||
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
|
||||
// text, such as "50 from SWWAF_ASN_LIMIT_PERCENT", or none when both are
|
||||
// left out.
|
||||
func percentText(percent *int64, setting string) string {
|
||||
switch {
|
||||
@@ -1,6 +1,6 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
|
||||
// The heuristic blocklists. It reads SWWAF_BLOCKLIST_ACTION, and the blocklists
|
||||
// through the reputation lists.
|
||||
|
||||
import (
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// blocklists notes the blocklists that list the client, as noteListed does,
|
||||
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it
|
||||
// and refuses the request when SWWAF_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{}
|
||||
@@ -23,19 +23,12 @@ func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
rq.blocklisted = len(listedBy) > 0
|
||||
rq.noteListed(listedBy, "listed by a blocklist")
|
||||
|
||||
if !rq.refusedByBlocklists(listedBy) {
|
||||
if !rq.blocklisted || rq.h.config.BlocklistAction != deny {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionClientDenied,
|
||||
action: requestlog.ActionDenied,
|
||||
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
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
|
||||
// The heuristic byte_limits. It reads SWWAF_BYTES_COUNT, and the byte limits
|
||||
// and biased thresholds through the limiter and limitPercentages.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// byteLimits counts the request's bytes, as countedBytes gives them, for the
|
||||
@@ -26,7 +28,7 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
|
||||
}
|
||||
|
||||
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
||||
rq.countedBytes(), rq.bytesLimitPercent.percent)
|
||||
rq.countedBytes(), rq.bytesPercent.percent)
|
||||
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
||||
rq.line.Counts.HourBytes = counts.HourBytes
|
||||
rq.line.Counts.DayBytes = counts.DayBytes
|
||||
@@ -37,5 +39,5 @@ func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict
|
||||
|
||||
rq.banForLimit(now, hit, rq.out.status)
|
||||
|
||||
return &verdict{figures: limitFigures(hit)}
|
||||
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -19,13 +19,13 @@ import (
|
||||
|
||||
// The byte limit settings.
|
||||
const (
|
||||
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWW_BYTES_COUNT"
|
||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWWAF_BYTES_COUNT"
|
||||
)
|
||||
|
||||
// The values of SWW_BYTES_COUNT.
|
||||
// The values of SWWAF_BYTES_COUNT.
|
||||
const (
|
||||
countResponse = "response"
|
||||
countRequest = "request"
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||
// with a body and its answer, 100 bytes, go over it.
|
||||
byteLimit = "99"
|
||||
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
|
||||
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE.
|
||||
minuteBytes = "minute_bytes"
|
||||
)
|
||||
|
||||
@@ -90,9 +90,9 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
||||
line.BanExpires, tc.window, expires)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
|
||||
wantMetric(t, s.scrape(), `smallwebwaf_rate_limit_hits_total{`+
|
||||
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||
})
|
||||
}
|
||||
@@ -104,12 +104,11 @@ func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
|
||||
|
||||
line, _ := s.download()
|
||||
wantAfterAnswerHeuristic(t, line, "", "null")
|
||||
wantHeuristic(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",
|
||||
wantHeuristic(t, line, "byte_limits",
|
||||
`{"window": "minute", "count": 140, "limit": 99}`)
|
||||
}
|
||||
|
||||
@@ -125,7 +124,7 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
||||
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||
@@ -147,7 +146,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||
answerBytes, minuteBytes, expires)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||
@@ -176,7 +175,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -218,7 +217,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -227,8 +226,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{
|
||||
@@ -386,7 +385,7 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
|
||||
// counted, and the byte limit is not broken. Its line gives the bytes
|
||||
// counted before it.
|
||||
line := s.upload()
|
||||
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
||||
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||
|
||||
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",
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"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
|
||||
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
||||
// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
||||
// length SWWAF_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.
|
||||
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
||||
addr = addr.Unmap()
|
||||
@@ -1,7 +1,7 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
|
||||
// and SWW_WAF_ANOMALY_THRESHOLD.
|
||||
// The heuristic core_rule_set. It reads SWWAF_WAF_MODE, SWWAF_WAF_EXEMPT_PATHS
|
||||
// and SWWAF_WAF_ANOMALY_THRESHOLD.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,18 +9,17 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// coreRuleSet inspects the request with the Core Rule Set, unless
|
||||
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
||||
// SWWAF_WAF_MODE is off or SWWAF_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.
|
||||
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block 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" }
|
||||
@@ -38,12 +37,12 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
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
|
||||
rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
|
||||
rq.line.WAFRuleIDs = result.RuleIDs
|
||||
rq.line.WAFScore = &result.Score
|
||||
|
||||
for _, id := range result.RuleIDs {
|
||||
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
|
||||
rq.h.metrics.WAFMatched(cfg.WAFMode, id)
|
||||
}
|
||||
|
||||
threshold := cfg.WAFAnomalyThreshold
|
||||
@@ -51,27 +50,26 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.addTag(config.ExploitSignatureTag)
|
||||
rq.alertCoreRuleSetMatch(result)
|
||||
rq.alertWAFBlock(result)
|
||||
|
||||
if cfg.WAFMode == config.WAFModeDetect {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.blockedByCoreRuleSet = true
|
||||
rq.wafBlocked = true
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionBlockedByCoreRuleSet,
|
||||
action: requestlog.ActionWAFBlocked,
|
||||
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
|
||||
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
||||
// Rule Set scored at result, at or over SWWAF_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) {
|
||||
func (rq *request) alertWAFBlock(result waf.Result) {
|
||||
detail := map[string]any{
|
||||
"rule_ids": result.RuleIDs,
|
||||
"score": result.Score,
|
||||
@@ -87,14 +85,14 @@ func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
||||
}
|
||||
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventCoreRuleSetMatch,
|
||||
Event: alerts.EventWAFBlock,
|
||||
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",
|
||||
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
+62
-106
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
||||
// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and
|
||||
// its two modes that inspect requests.
|
||||
const (
|
||||
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
||||
block = "block"
|
||||
detect = "detect"
|
||||
)
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
// formData is the type of a form's body.
|
||||
const formData = "application/x-www-form-urlencoded"
|
||||
|
||||
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||
// sizeLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||
// setting.
|
||||
const (
|
||||
sizeLimit = 1 << 10
|
||||
@@ -37,72 +37,30 @@ const (
|
||||
|
||||
// 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
|
||||
// SWW_WAF_ANOMALY_THRESHOLD.
|
||||
// SWWAF_WAF_ANOMALY_THRESHOLD.
|
||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||
|
||||
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
|
||||
// core_rule_set_score, and that it has duration_core_rule_set_ms, or with no
|
||||
// score, that it has none of the three: the Core Rule Set did not inspect the
|
||||
// request.
|
||||
func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
||||
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
||||
// that it has duration_waf, or with no score, that it has none of the
|
||||
// three: the Core Rule Set did not inspect the request.
|
||||
func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
||||
t.Helper()
|
||||
|
||||
if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) {
|
||||
t.Errorf("log line has core_rule_set_rule_ids %v, want %v",
|
||||
line.CoreRuleSetRuleIDs, ruleIDs)
|
||||
if !slices.Equal(line.WAFRuleIDs, ruleIDs) {
|
||||
t.Errorf("log line has waf_rule_ids %v, want %v", line.WAFRuleIDs, ruleIDs)
|
||||
}
|
||||
|
||||
switch {
|
||||
case score == nil &&
|
||||
(line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil):
|
||||
t.Errorf("log line has core_rule_set_score %v and "+
|
||||
"duration_core_rule_set_ms %v, want neither",
|
||||
line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"])
|
||||
case score != nil &&
|
||||
(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")
|
||||
case score == nil && (line.WAFScore != nil || line.DurationWAF != nil):
|
||||
t.Errorf("log line has waf_score %v and duration_waf %v, want neither",
|
||||
line.fields["waf_score"], line.fields["duration_waf"])
|
||||
case score != nil && (line.WAFScore == nil || *line.WAFScore != *score):
|
||||
t.Errorf("log line has waf_score %v, want %d", line.fields["waf_score"], *score)
|
||||
case score != nil && line.DurationWAF == nil:
|
||||
t.Error("log line has no duration_waf")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -139,14 +97,14 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
|
||||
mode, action string
|
||||
status int
|
||||
}{
|
||||
{block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden},
|
||||
{block, requestlog.ActionWAFBlocked, http.StatusForbidden},
|
||||
{detect, requestlog.ActionForward, http.StatusOK},
|
||||
} {
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
||||
|
||||
line := s.requestWithHeader(client, attack.path, attack.header,
|
||||
tc.status, tc.action)
|
||||
wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...)
|
||||
wantWAF(t, line, &attack.score, attack.ruleIDs...)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -158,7 +116,7 @@ func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
requestlog.ActionWAFBlocked)
|
||||
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
|
||||
}
|
||||
|
||||
@@ -169,7 +127,7 @@ func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
||||
|
||||
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
|
||||
http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(0))
|
||||
wantWAF(t, line, new(0))
|
||||
}
|
||||
|
||||
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
||||
@@ -177,7 +135,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
wafMode: block,
|
||||
@@ -186,29 +144,29 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
||||
rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
|
||||
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
|
||||
// A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS
|
||||
// exempts, are not inspected.
|
||||
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
wantWAF(t, line, nil)
|
||||
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
|
||||
requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
wantWAF(t, line, nil)
|
||||
|
||||
// The prefix is matched as rate limit exempt paths are: a path that
|
||||
// goes up and out of it is inspected.
|
||||
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100)
|
||||
requestlog.ActionWAFBlocked)
|
||||
wantWAF(t, line, new(25), 930100, 930110, 942100)
|
||||
|
||||
// A request a rule file refuses is not inspected.
|
||||
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
||||
requestlog.ActionBlockedByBlockRule)
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
requestlog.ActionRuleBlocked)
|
||||
wantWAF(t, line, nil)
|
||||
|
||||
// With SWW_WAF_MODE off, no request is.
|
||||
// With SWWAF_WAF_MODE off, no request is.
|
||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
wantWAF(t, line, nil)
|
||||
}
|
||||
|
||||
func TestAnomalyThreshold(t *testing.T) {
|
||||
@@ -222,14 +180,13 @@ func TestAnomalyThreshold(t *testing.T) {
|
||||
})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(5), 942100)
|
||||
wantWAF(t, line, new(5), 942100)
|
||||
}
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
wafMode: block, wafAnomalyThreshold: "5",
|
||||
})
|
||||
s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
||||
}
|
||||
|
||||
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
||||
@@ -272,7 +229,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
line, _ := s.requestWithBody(request.method, client, request.path,
|
||||
request.header, body, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(0))
|
||||
wantWAF(t, line, new(0))
|
||||
|
||||
// A list given replaces the default.
|
||||
s, _, _ = startWithClock(t, "", map[string]string{
|
||||
@@ -280,12 +237,12 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
||||
})
|
||||
score := 5 * len(request.ruleIDs)
|
||||
line, _ = s.requestWithBody(request.method, client, request.path,
|
||||
request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||
wantCoreRuleSet(t, line, &score, request.ruleIDs...)
|
||||
request.header, body, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
||||
wantWAF(t, line, &score, request.ruleIDs...)
|
||||
|
||||
// And switches off the rules it lists.
|
||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(0))
|
||||
wantWAF(t, line, new(0))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -301,14 +258,14 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||
http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(0))
|
||||
wantWAF(t, line, new(0))
|
||||
|
||||
s, _, _ = startWithClock(t, "", map[string]string{
|
||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
||||
})
|
||||
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||
wantCoreRuleSet(t, line, new(5), 942100)
|
||||
http.StatusForbidden, requestlog.ActionWAFBlocked)
|
||||
wantWAF(t, line, new(5), 942100)
|
||||
}
|
||||
|
||||
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
||||
@@ -398,33 +355,32 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
||||
}
|
||||
|
||||
// The third refusal in a minute breaks the error burst, and bans the
|
||||
// client.
|
||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
||||
requestlog.ActionWAFBlocked)
|
||||
if line.LimitHit != requestlog.LimitHitErrorBurst ||
|
||||
line.Offence != requestlog.OffenceLimit {
|
||||
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit",
|
||||
t.Errorf("log line has limit_hit %q and offence %q, want error_burst and limit",
|
||||
line.LimitHit, line.Offence)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
want := ratelimit.Offences{Limit: 1, BlockedByCoreRuleSet: 3}
|
||||
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
|
||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
||||
}
|
||||
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
||||
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
||||
wantMetric(t, metrics,
|
||||
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3)
|
||||
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+
|
||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+
|
||||
`instance="app",status_class="4xx"}`, 3)
|
||||
}
|
||||
|
||||
@@ -448,9 +404,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
|
||||
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
||||
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
||||
wantNoSeries(t, metrics,
|
||||
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`)
|
||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`)
|
||||
}
|
||||
|
||||
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
||||
@@ -459,17 +415,17 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
||||
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet)
|
||||
wantCoreRuleSet(t, line, new(5), 942100)
|
||||
wantWouldAction(t, line, requestlog.ActionWAFBlocked)
|
||||
wantWAF(t, line, new(5), 942100)
|
||||
|
||||
// It is an offence as in enforce mode.
|
||||
want := ratelimit.Offences{BlockedByCoreRuleSet: 1}
|
||||
want := ratelimit.Offences{WAFBlocked: 1}
|
||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
||||
func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -483,7 +439,7 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
"block", map[string]string{wafMode: block},
|
||||
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
|
||||
http.StatusForbidden, requestlog.ActionWAFBlocked, "",
|
||||
},
|
||||
{
|
||||
"detect", map[string]string{wafMode: detect},
|
||||
@@ -518,11 +474,11 @@ func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
||||
wantAlerts(t, queue, alerts.Alert{
|
||||
Instance: alertInstance,
|
||||
Time: clk.Now(),
|
||||
Event: alerts.EventCoreRuleSetMatch,
|
||||
Event: alerts.EventWAFBlock,
|
||||
Host: appHost,
|
||||
Client: netip.MustParseAddr(client),
|
||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
||||
Detail: detail,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic countries. It reads SWWAF_DENIED_COUNTRIES and
|
||||
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// countries refuses a request by the client's country as it was looked up. A
|
||||
// client without a country, or whose country cannot be found, is refused only
|
||||
// by SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. 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 {
|
||||
denied := rq.h.config.DeniedCountries
|
||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||
country := rq.line.Country
|
||||
|
||||
var setting string
|
||||
|
||||
switch {
|
||||
case slices.Contains(denied, country):
|
||||
setting = "SWWAF_DENIED_COUNTRIES"
|
||||
case len(allowed) > 0 && !slices.Contains(allowed, country):
|
||||
setting = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionCountryDenied,
|
||||
figures: map[string]any{"country": country, "setting": setting},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
|
||||
env map[string]string
|
||||
refused []string
|
||||
}{
|
||||
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
|
||||
{
|
||||
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
||||
[]string{fromKP, unplaced},
|
||||
},
|
||||
{
|
||||
"with a country in SWW_TAG_DENY",
|
||||
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
|
||||
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"},
|
||||
[]string{fromKP, unplaced},
|
||||
},
|
||||
} {
|
||||
@@ -95,16 +95,16 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupTimeout: "1h", allowedCountries: "de",
|
||||
lookupTimeout: "1h", deniedCountries: "kp", allowedCountries: "de",
|
||||
})
|
||||
|
||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||
wantHeuristic(t, line, "countries",
|
||||
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||
`{"country": "KP", "setting": "SWWAF_DENIED_COUNTRIES"}`)
|
||||
|
||||
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||
wantHeuristic(t, line, "countries",
|
||||
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||
`{"country": "", "setting": "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||
}
|
||||
|
||||
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||
@@ -117,9 +117,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||
})
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
allowedCountries: "de",
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
|
||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||
@@ -202,7 +202,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
||||
env map[string]string
|
||||
}{
|
||||
{"no setting needs the lookup", nil},
|
||||
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
|
||||
{"a country list is set", map[string]string{deniedCountries: "kp"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -262,11 +262,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
||||
action string
|
||||
}{
|
||||
{
|
||||
"not in SWW_ALLOW_NETS", "",
|
||||
"not in SWWAF_ALLOW_NETS", "",
|
||||
http.StatusForbidden, requestlog.ActionCountryDenied,
|
||||
},
|
||||
{
|
||||
"in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
||||
"in SWWAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
||||
http.StatusOK, requestlog.ActionForward,
|
||||
},
|
||||
} {
|
||||
@@ -296,7 +296,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
||||
// 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
|
||||
// what returns the addresses it has been asked about. A test that needs
|
||||
// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
|
||||
// the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an
|
||||
// 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
|
||||
// leave the client unknown.
|
||||
@@ -367,7 +367,7 @@ const (
|
||||
)
|
||||
|
||||
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
||||
// sww reads.
|
||||
// smallwebwaf reads.
|
||||
//
|
||||
//nolint:tagliatelle // GeoJS's own names
|
||||
type geojsAnswer struct {
|
||||
@@ -0,0 +1,39 @@
|
||||
package servewaf
|
||||
|
||||
// The heuristic crowdsec. It reads SWWAF_CROWDSEC_LAPI_URL, and the decisions
|
||||
// through the reputation lists.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"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 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.h.lists.CrowdSecDecision(rq.client, now)
|
||||
if !listed {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
||||
"listed by the CrowdSec decision list")
|
||||
rq.banForCrowdSec(now, decision)
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionBanned,
|
||||
figures: map[string]any{
|
||||
"scenario": decision.Scenario,
|
||||
"expires": requestlog.FormatTime(decision.Expires),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -9,17 +9,17 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// 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
|
||||
// needs, as reputation.json would at start.
|
||||
const (
|
||||
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
||||
lapi = "http://crowdsec.example:8080"
|
||||
decisionsURL = lapi + "/v1/decisions"
|
||||
bouncerKey = "crowdsec-key-0123456789abcdef"
|
||||
@@ -39,7 +39,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
||||
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
||||
|
||||
// Its first request is refused, and bans it until the decision ends.
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
wantReputation(t, line, decisionsURL)
|
||||
|
||||
if line.BanExpires != expires {
|
||||
@@ -72,7 +72,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
||||
// ban, and once it has ended the client is let through.
|
||||
clk.advance(4*time.Hour - time.Second)
|
||||
|
||||
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
wantReputation(t, line)
|
||||
|
||||
if line.BanExpires != expires {
|
||||
@@ -87,11 +87,11 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
||||
metrics := s.scrape()
|
||||
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
||||
|
||||
wantMetric(t, metrics, `sww_bans_made_total{cause="crowdsec",`+
|
||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
|
||||
`instance="`+alertInstance+`"}`, 1)
|
||||
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
|
||||
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1)
|
||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
||||
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
||||
float64(fetched.Unix()))
|
||||
}
|
||||
|
||||
@@ -105,10 +105,9 @@ func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
|
||||
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
||||
`"value": "`+client+`"}]`)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
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) {
|
||||
@@ -129,9 +128,9 @@ func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T
|
||||
// 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.
|
||||
clk.advance(time.Minute - time.Nanosecond)
|
||||
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned)
|
||||
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned)
|
||||
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
// Once it has ended, it bans no other client, and the bans it made end
|
||||
// with it.
|
||||
@@ -164,7 +163,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
||||
`"value": "`+client+`"}]`)
|
||||
|
||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||
wantReputation(t, line, decisionsURL)
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
@@ -184,7 +183,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
||||
// reputation.json would at start.
|
||||
func loadDecisions(
|
||||
t *testing.T, server *discriminator.Server, fetched time.Time, answer string,
|
||||
t *testing.T, server *servewaf.Server, fetched time.Time, answer string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
// The heuristic deny_nets. It reads SWW_DENY_NETS.
|
||||
// The heuristic deny_nets. It reads SWWAF_DENY_NETS.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of
|
||||
// denyNets refuses a client in SWWAF_DENY_NETS. Its figure is the netblock of
|
||||
// the setting the client is in.
|
||||
type denyNets struct{}
|
||||
|
||||
@@ -19,8 +19,8 @@ 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()},
|
||||
action: requestlog.ActionDenied,
|
||||
figures: map[string]any{"netblock": netblock.String()},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
|
||||
// The heuristic dnsbl. It reads SWWAF_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
|
||||
// does, and refuses the request when SWWAF_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
|
||||
@@ -28,16 +25,12 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||
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,
|
||||
action: requestlog.ActionDenied,
|
||||
figures: map[string]any{"zones": listedBy},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
// The heuristic header_limits. It reads no setting: its limits are fixed.
|
||||
|
||||
@@ -25,12 +25,12 @@ 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) {
|
||||
if fields <= maxHeaderFields && cookies <= maxCookies {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionRequestHeaderTooLarge,
|
||||
action: requestlog.ActionTooLarge,
|
||||
figures: map[string]any{
|
||||
"fields": fields, "fields_limit": maxHeaderFields,
|
||||
"cookies": cookies, "cookies_limit": maxCookies,
|
||||
@@ -38,10 +38,12 @@ func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// overHeaderLimits reports whether the request is over the header limits: it
|
||||
// has more than maxHeaderFields fields or more than maxCookies cookies, as
|
||||
// headerCounts counts them.
|
||||
func (rq *request) overHeaderLimits() bool {
|
||||
fields, cookies := rq.headerCounts()
|
||||
|
||||
return fields > maxHeaderFields || cookies > maxCookies
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||
@@ -21,8 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||
)
|
||||
|
||||
for range healthChecks {
|
||||
got := s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "",
|
||||
http.StatusOK)
|
||||
got := s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK)
|
||||
|
||||
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user