Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8737103eac | ||
|
|
719691d1e8 | ||
|
|
aede434f6a | ||
|
|
6850cb0e9f | ||
|
|
d7c62f2854 | ||
|
|
c66277b111 | ||
|
|
f6435b1a73 | ||
|
|
2349b2a081 | ||
|
|
42e06bf298 | ||
|
|
beb503b233 | ||
|
|
2e2f6f7175 | ||
|
|
7fed6b6d36 |
No files matched your search
+25
-16
@@ -87,7 +87,7 @@ COPY . .
|
|||||||
# .git present, a version that is still empty, dev or unknown fails the
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
# build: git is missing or could not read the checkout. The no_fs_access
|
# build: git is missing or could not read the checkout. The no_fs_access
|
||||||
# tag keeps Coraza from writing the files of a multipart body to the
|
# tag keeps Coraza from writing the files of a multipart body to the
|
||||||
# system's temporary directory, since smallwebwaf writes only to its state
|
# system's temporary directory, since sww writes only to its state
|
||||||
# directory.
|
# directory.
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
@@ -99,7 +99,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
|||||||
fi; \
|
fi; \
|
||||||
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
|
-o /usr/local/bin/sww ./cmd/sww
|
||||||
|
|
||||||
# runsvinit, the image's entrypoint, built at the last commit of its
|
# runsvinit, the image's entrypoint, built at the last commit of its
|
||||||
# archived repository. It has no go.mod, and `go build` of its directory
|
# archived repository. It has no go.mod, and `go build` of its directory
|
||||||
@@ -118,7 +118,7 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
|||||||
-o /usr/local/bin/runsvinit .
|
-o /usr/local/bin/runsvinit .
|
||||||
|
|
||||||
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
||||||
# SMALLWEBWAF_COMMAND says servewaf, and an app's Dockerfile can build FROM it.
|
# SWW_COMMAND says discriminator, and an app's Dockerfile can build FROM it.
|
||||||
# It is the last stage, so a plain `docker build .` builds it.
|
# It is the last stage, so a plain `docker build .` builds it.
|
||||||
#
|
#
|
||||||
# ubuntu 26.04, 2026-09-27
|
# ubuntu 26.04, 2026-09-27
|
||||||
@@ -136,10 +136,19 @@ RUN mkdir /etc/sysusers.d \
|
|||||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
||||||
# them over plain HTTP and checks each one against the archive's signed
|
# them over plain HTTP and checks each one against the archive's signed
|
||||||
# package lists, so no CA certificates are needed.
|
# package lists, so no CA certificates are needed.
|
||||||
RUN apt-get update \
|
#
|
||||||
|
# nix-bin depends on xz-utils, whose install links man pages with
|
||||||
|
# update-alternatives. The image's dpkg config drops man pages, so each
|
||||||
|
# link warns. z-man, read after /etc/dpkg/dpkg.cfg.d/excludes, re-includes
|
||||||
|
# the man1 pages for this install alone; the same step deletes them again,
|
||||||
|
# so no man page or package is added.
|
||||||
|
RUN echo 'path-include=/usr/share/man/man1/*' > /etc/dpkg/dpkg.cfg.d/z-man \
|
||||||
|
&& apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||||
ca-certificates nix-bin runit \
|
ca-certificates nix-bin runit \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/* /etc/dpkg/dpkg.cfg.d/z-man \
|
||||||
|
&& find /usr/share/man/man1 ! -type d -delete \
|
||||||
|
&& find /etc/alternatives -type l -xtype l -delete
|
||||||
|
|
||||||
# Nix run by root expects a group of build users, which nix-bin does not
|
# Nix run by root expects a group of build users, which nix-bin does not
|
||||||
# create; with the setting empty, root's builds run without them.
|
# create; with the setting empty, root's builds run without them.
|
||||||
@@ -166,27 +175,27 @@ RUN curl -fsSL -o /tmp/nixexprs.tar.xz \
|
|||||||
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
||||||
|
|
||||||
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
||||||
COPY --from=builder /usr/local/bin/smallwebwaf /usr/local/bin/smallwebwaf
|
COPY --from=builder /usr/local/bin/sww /usr/local/bin/sww
|
||||||
|
|
||||||
# 65532 is above the uids Ubuntu keeps for system users, which end at
|
# 65532 is above the uids Ubuntu keeps for system users, which end at
|
||||||
# 999; useradd warns about it unless --key raises that end for this call.
|
# 999; useradd warns about it unless --key raises that end for this call.
|
||||||
RUN groupadd --system --gid 65532 smallwebwaf \
|
RUN groupadd --system --gid 65532 sww \
|
||||||
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
||||||
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \
|
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
||||||
smallwebwaf
|
sww
|
||||||
|
|
||||||
# The state files' directory, SWWAF_STATE_DIR by default, where a volume
|
# The state files' directory, SWW_STATE_DIR by default, where a volume
|
||||||
# is mounted to keep them across deploys. The run script gives it to the
|
# is mounted to keep them across deploys. The run script gives it to the
|
||||||
# smallwebwaf user at each start of servewaf.
|
# sww user at each start of discriminator.
|
||||||
RUN mkdir /var/lib/smallwebwaf
|
RUN mkdir /var/lib/sww
|
||||||
|
|
||||||
# The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile
|
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
||||||
# built FROM this image can copy rule files of its own beside it.
|
# built FROM this image can copy rule files of its own beside it.
|
||||||
COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules
|
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
||||||
|
|
||||||
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
||||||
# looks too.
|
# looks too.
|
||||||
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
|
COPY --chmod=755 share/sww.run /etc/service/sww/run
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
@@ -194,6 +203,6 @@ EXPOSE 8080
|
|||||||
# check runs every second from the start until it first passes, for up
|
# check runs every second from the start until it first passes, for up
|
||||||
# to a minute, and every 30 seconds after that.
|
# to a minute, and every 30 seconds after that.
|
||||||
HEALTHCHECK --start-period=1m --start-interval=1s \
|
HEALTHCHECK --start-period=1m --start-interval=1s \
|
||||||
CMD ["/usr/local/bin/smallwebwaf", "healthcheck"]
|
CMD ["/usr/local/bin/sww", "healthcheck"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
||||||
+58
-46
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
|
|||||||
- R1: rate limits per minute, per hour and per day
|
- R1: rate limits per minute, per hour and per day
|
||||||
- R2: netblocks that bypass rate limiting
|
- R2: netblocks that bypass rate limiting
|
||||||
- R3: alerts on attacks through webhook, Slack or ntfy
|
- R3: alerts on attacks through webhook, Slack or ntfy
|
||||||
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs
|
- R4: alerts on anomalies: requests made or bytes transferred per minute or
|
||||||
or netblocks, crossing a threshold
|
hour, for given IPs or netblocks, crossing a threshold
|
||||||
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
||||||
- R6: one or more RBLs or IP reputation APIs
|
- R6: one or more RBLs or IP reputation APIs
|
||||||
- R7: AS number lookup
|
- R7: AS number lookup
|
||||||
@@ -24,14 +24,19 @@ configured only by environment variables, that provides:
|
|||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
|
Nothing existing is suitable. No surveyed tool meets R8, which asks for limits
|
||||||
number or country) or the bytes half of R4 at all, and the tools that come
|
scaled by AS number or country. None meets R4's alerts on bytes transferred. The
|
||||||
closest on the rest fail R10: they need several containers, YAML or a web UI.
|
tools that come closest on the rest fail R10: they need several containers, YAML
|
||||||
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
|
or a web UI. The recommendation is to build a small single-binary sidecar;
|
||||||
directory begins that spec. It reuses the mature parts of the surveyed field as
|
`SPEC.md` in this directory begins that spec.
|
||||||
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
|
|
||||||
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of
|
The sidecar reuses the mature parts of the surveyed field instead of
|
||||||
reimplementing them.
|
reimplementing them. It takes these as libraries and data sources:
|
||||||
|
|
||||||
|
- Coraza and the OWASP Core Rule Set, for attack detection
|
||||||
|
- DNSBLs
|
||||||
|
- AbuseIPDB
|
||||||
|
- optionally, a CrowdSec decision feed
|
||||||
|
|
||||||
Closest existing options, and why each still falls short:
|
Closest existing options, and why each still falls short:
|
||||||
|
|
||||||
@@ -45,13 +50,16 @@ Closest existing options, and why each still falls short:
|
|||||||
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
|
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
|
||||||
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
||||||
Set, or Coraza plugin), env-var settings.
|
Set, or Coraza plugin), env-var settings.
|
||||||
- Fails: R8 (AS number and country can only allow or deny, never scale a
|
- Fails:
|
||||||
limit), R4 (no volume or byte threshold alerts in the free edition;
|
- R8: AS number and country can only allow or deny, never scale a limit.
|
||||||
reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6
|
- R4: no alerts on request-count or byte thresholds in the free edition;
|
||||||
it needs a `bunkerweb` container plus a `bw-scheduler` container and a
|
reporting is a paid feature.
|
||||||
database, or the all-in-one image that bundles nginx, scheduler, UI and
|
- R5: no escalation for repeat offenders.
|
||||||
Redis in one container. It is designed to be the front door for many
|
- R10: since 1.6 it is not one plain sidecar. It needs a `bunkerweb`
|
||||||
sites, not a per-app sidecar. AGPL-3.0.
|
container, a `bw-scheduler` container and a database, or the
|
||||||
|
all-in-one image that bundles nginx, the scheduler, the UI and Redis.
|
||||||
|
- It is designed as the proxy in front of many sites, not as a sidecar for
|
||||||
|
one app. AGPL-3.0.
|
||||||
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
||||||
the same URL pattern; the documented design is one rate per URL pattern.
|
the same URL pattern; the documented design is one rate per URL pattern.
|
||||||
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
|
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
|
||||||
@@ -66,16 +74,20 @@ Closest existing options, and why each still falls short:
|
|||||||
listed AS numbers is possible, but each is a hand-written YAML scenario,
|
listed AS numbers is possible, but each is a hand-written YAML scenario,
|
||||||
it reacts after the fact by banning, and it is not an inline limiter that
|
it reacts after the fact by banning, and it is not an inline limiter that
|
||||||
answers 429.
|
answers 429.
|
||||||
- Fails: R10 (needs the security engine container with persistent state, log
|
- Fails:
|
||||||
acquisition from traefik, a bouncer such as the traefik plugin, and YAML
|
- R10, because it needs:
|
||||||
for acquisition, profiles, scenarios and notifications), bytes half of R4.
|
- the security engine container with persistent state
|
||||||
|
- log acquisition from traefik
|
||||||
|
- a bouncer such as the traefik plugin
|
||||||
|
- YAML for acquisition, profiles, scenarios and notifications
|
||||||
|
- R4's alerts on bytes.
|
||||||
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
||||||
with no new code. It gives inline limiting (one window per middleware, keyed
|
with no new code. It gives inline limiting (one window per middleware, keyed
|
||||||
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
||||||
reputation. It still fails R8, the bytes half of R4, R1's three stacked
|
reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely:
|
||||||
windows only by chaining three middlewares per router, and R10 entirely: the
|
the configuration lives in traefik labels and CrowdSec YAML on each host, not
|
||||||
configuration lives in traefik labels and CrowdSec YAML on each host, not in
|
in one sidecar's environment. It meets R1's three windows only by chaining
|
||||||
one sidecar's environment.
|
three middlewares per router.
|
||||||
|
|
||||||
## Candidate by candidate
|
## Candidate by candidate
|
||||||
|
|
||||||
@@ -87,10 +99,10 @@ Closest existing options, and why each still falls short:
|
|||||||
cached, streamed decision list (recommended), standalone against the
|
cached, streamed decision list (recommended), standalone against the
|
||||||
central API, or AppSec only. Supports captcha remediation.
|
central API, or AppSec only. Supports captcha remediation.
|
||||||
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
||||||
- Misses: the plugin itself does no rate limiting; R8; R4 bytes.
|
- Misses: the plugin itself does no rate limiting; R8; R4's alerts on bytes.
|
||||||
- Configuration: traefik static config to load the plugin, dynamic config or
|
- Configuration: traefik static config to load the plugin, dynamic config or
|
||||||
labels for the middleware, CrowdSec YAML for everything else.
|
labels for the middleware, CrowdSec YAML for everything else.
|
||||||
- Sidecar fit: no. It lives inside traefik, plus a separate engine
|
- Sidecar fit: no. It runs inside traefik and needs a separate engine
|
||||||
container.
|
container.
|
||||||
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
||||||
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
||||||
@@ -115,7 +127,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
||||||
semantic detection engine and a web console. About 22,000 stars.
|
semantic detection engine and a web console. About 22,000 stars.
|
||||||
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
||||||
challenge, authentication gate.
|
challenge, authentication in front of the app.
|
||||||
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
||||||
messages, syslog forwarding and the better reputation database are paid
|
messages, syslog forwarding and the better reputation database are paid
|
||||||
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
||||||
@@ -128,7 +140,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
||||||
- What it is: a Go library that implements the ModSecurity rule language and
|
- What it is: a Go library that implements the ModSecurity rule language and
|
||||||
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
||||||
successor path now that ModSecurity is in maintenance only.
|
successor to ModSecurity, which is now in maintenance only.
|
||||||
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
||||||
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
||||||
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
||||||
@@ -142,7 +154,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
||||||
- What it is: official images of Apache or nginx with ModSecurity and the
|
- What it is: official images of Apache or nginx with ModSecurity and the
|
||||||
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
|
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
|
||||||
paranoia level, anomaly thresholds, many more). Current images carry
|
paranoia level, anomaly thresholds, many more). Current images contain
|
||||||
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
||||||
- Covers: R9, and R10 (single container, env vars, one backend).
|
- Covers: R9, and R10 (single container, env vars, one backend).
|
||||||
- Misses: R1 to R8.
|
- Misses: R1 to R8.
|
||||||
@@ -152,16 +164,16 @@ Closest existing options, and why each still falls short:
|
|||||||
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
||||||
- What it is: a single-binary reverse proxy that makes browsers solve a
|
- What it is: a single-binary reverse proxy that makes browsers solve a
|
||||||
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
||||||
scrapers, which is likely a large share of unwanted traffic on a public
|
scrapers, which are likely a large share of the unwanted traffic on a
|
||||||
gitea.
|
public code forge.
|
||||||
- Covers: R10 well (one container, env vars for listener, target,
|
- Covers: R10 well (one container, env vars for listener, target,
|
||||||
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
||||||
IP ranges, and with the vendor's hosted data service also AS number and
|
IP ranges, and with the vendor's hosted data service also AS number and
|
||||||
country, and can weigh a request toward a harder challenge.
|
country, and can weigh a request toward a harder challenge.
|
||||||
- Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env
|
- Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env
|
||||||
vars. AS number and country matching depend on the vendor's hosted
|
vars. AS number and country matching depend on the vendor's hosted
|
||||||
service. Breaks non-browser clients unless paths are exempted; for gitea,
|
service. Breaks non-browser clients unless paths are exempted; for a code
|
||||||
git-over-HTTP and API paths must be allowed through by rule.
|
forge, git-over-HTTP and API paths must be allowed through by rule.
|
||||||
- Maturity: very active, widely deployed on code forges since 2025.
|
- Maturity: very active, widely deployed on code forges since 2025.
|
||||||
- Value here: complementary. It can be chained (traefik, then the sidecar,
|
- Value here: complementary. It can be chained (traefik, then the sidecar,
|
||||||
then Anubis, then the app) if challenge pages are wanted.
|
then Anubis, then the app) if challenge pages are wanted.
|
||||||
@@ -180,13 +192,13 @@ Closest existing options, and why each still falls short:
|
|||||||
temporary ban, no escalation, state lost on traefik restart) and R2
|
temporary ban, no escalation, state lost on traefik restart) and R2
|
||||||
(allowlist). Nothing else.
|
(allowlist). Nothing else.
|
||||||
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
||||||
plugins (country allow or deny through an external lookup API): same
|
plugins (country allow or deny through an external lookup API): each adds
|
||||||
shape, each one small feature, configured in traefik, state in memory.
|
one small feature, is configured in traefik and keeps its state in memory.
|
||||||
- Sidecar fit: no; they live inside traefik.
|
- Sidecar fit: no; they live inside traefik.
|
||||||
- Traefik built-in middlewares
|
- Traefik built-in middlewares
|
||||||
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
||||||
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
||||||
or AS number awareness.
|
lookups or AS number lookups.
|
||||||
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
||||||
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
|
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
|
||||||
and per-path rate limiting with one configurable window, IP and DNS
|
and per-path rate limiting with one configurable window, IP and DNS
|
||||||
@@ -198,12 +210,12 @@ Closest existing options, and why each still falls short:
|
|||||||
- open-appsec (Check Point)
|
- open-appsec (Check Point)
|
||||||
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
|
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
|
||||||
a declarative policy file or the vendor's cloud console. Covers R9 only;
|
a declarative policy file or the vendor's cloud console. Covers R9 only;
|
||||||
rate limiting and richer features are in paid tiers. Not a sidecar in the
|
rate limiting and richer features are in paid tiers. It attaches to an
|
||||||
required sense.
|
existing proxy, so it is not the sidecar R10 asks for.
|
||||||
- iocaine
|
- iocaine
|
||||||
- Serves generated garbage pages to clients the fronting proxy classifies as
|
- Serves generated garbage pages to clients the fronting proxy classifies as
|
||||||
scrapers. Not a limiter, WAF or ban tool; out of scope except as a
|
scrapers. Not a limiter, WAF or ban tool; out of scope, and only of
|
||||||
curiosity for scraper traffic.
|
passing interest for scraper traffic.
|
||||||
- Pangolin
|
- Pangolin
|
||||||
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
||||||
Replaces the ingress rather than adding a sidecar; out of scope.
|
Replaces the ingress rather than adding a sidecar; out of scope.
|
||||||
@@ -214,17 +226,17 @@ Closest existing options, and why each still falls short:
|
|||||||
Everyone else offers one window per rule or none.
|
Everyone else offers one window per rule or none.
|
||||||
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
||||||
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
||||||
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count
|
- R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts
|
||||||
anomalies with a custom scenario; nothing handles bytes.
|
on request counts with a custom scenario; no surveyed tool alerts on bytes.
|
||||||
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent,
|
- R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one
|
||||||
not both by history.
|
fixed length or permanent, and does not change with a client's history.
|
||||||
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
||||||
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
||||||
deny), Anubis (hosted service).
|
deny), Anubis (hosted service).
|
||||||
- R8 biased thresholds: none.
|
- R8 biased thresholds: none.
|
||||||
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
||||||
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the
|
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza
|
||||||
first).
|
or ModSecurity).
|
||||||
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
||||||
None of these covers anything beyond its one job.
|
None of these covers anything beyond its one job.
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
|
||||||
run example-app gitea-requests gitea-setup
|
hooks build run example-app
|
||||||
|
|
||||||
# Makefile targets are thin shims; the implementations live in script/
|
# Makefile targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||||
# which test checks. build and run are for working on the code by hand;
|
# which test checks. build and run are for working on the code by hand;
|
||||||
# example-app checks the image with an app built on it; gitea-requests
|
# example-app checks the image with an app built on it; publish builds
|
||||||
# reports the gitea requests the Core Rule Set refuses at the defaults;
|
# the image on the Docker host and pushes it to the registry.
|
||||||
# gitea-setup checks the setup in deploy/gitea.
|
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -36,6 +35,9 @@ check:
|
|||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
|
publish:
|
||||||
|
@script/publish
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
@@ -47,9 +49,3 @@ run:
|
|||||||
|
|
||||||
example-app:
|
example-app:
|
||||||
@script/example-app
|
@script/example-app
|
||||||
|
|
||||||
gitea-requests:
|
|
||||||
@script/gitea-requests
|
|
||||||
|
|
||||||
gitea-setup:
|
|
||||||
@script/gitea-setup
|
|
||||||
@@ -7,29 +7,29 @@ requests of scrapers, scanners and other abusers before they reach an app.
|
|||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
A web application firewall reads each request on its way to a web app and
|
A web application firewall reads each request on its way to a web app and
|
||||||
refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in
|
refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
|
||||||
one image, with two commands that do this together:
|
shipped in one image, with two commands that do this together:
|
||||||
|
|
||||||
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik
|
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
|
||||||
routes to it. It asks `servewaf` about each request, passes the allowed ones
|
it. It asks `discriminator` about each request, passes the allowed ones to the
|
||||||
to the app, and passes the app's answers back. It keeps nothing and never
|
app, and passes the app's answers back. It keeps nothing and never touches
|
||||||
touches disk.
|
disk.
|
||||||
- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the
|
- `sww discriminator` decides. It runs the checks and keeps the bans, the
|
||||||
counters and each client's history. One `servewaf` serves many sites: a client
|
counters and each client's history. One `discriminator` serves many sites: a
|
||||||
banned through one site is refused on all of them, and its requests on all of
|
client banned through one site is refused on all of them, and its requests on
|
||||||
them count toward one set of limits.
|
all of them count toward one set of limits.
|
||||||
|
|
||||||
The site keeps answering while `servewaf` is down, slow or restarting: without
|
The site keeps answering while `discriminator` is down, slow or restarting:
|
||||||
an answer in time, `rproxy` passes the request to the app.
|
without an answer in time, `rproxy` passes the request to the app.
|
||||||
|
|
||||||
It protects against:
|
It protects against:
|
||||||
|
|
||||||
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
||||||
requests and on bytes per minute, per hour and per day. The defaults leave
|
requests and on body bytes per minute, per hour and per day. The defaults
|
||||||
room for a person reading a site for hours. They catch a scraper that keeps up
|
leave room for a person reading a site for hours. They catch a scraper that
|
||||||
one request every 2 seconds. A client over a limit is refused and banned: for
|
keeps up one request every 2 seconds. A client that breaks a limit is banned:
|
||||||
a while at first, for three times as long at each repeat, and permanently when
|
for a while at first, for three times as long at each repeat, and permanently
|
||||||
it keeps at it.
|
when it keeps at it.
|
||||||
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
||||||
default rule file bans such a client at its first probe, and its next request
|
default rule file bans such a client at its first probe, and its next request
|
||||||
during the ban makes the ban permanent. Your own rules, or a list of trap
|
during the ban makes the ban permanent. Your own rules, or a list of trap
|
||||||
@@ -68,8 +68,9 @@ Not built, and not planned:
|
|||||||
|
|
||||||
### The image
|
### The image
|
||||||
|
|
||||||
No image is published. Build it from a clone. `make docker` runs the tests and
|
The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
|
||||||
the linter, then builds the image, tagged `smallwebwaf`.
|
from a clone. `make docker` runs the tests and the linter, then builds the
|
||||||
|
image, tagged `smallwebwaf`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||||
@@ -79,11 +80,11 @@ make docker
|
|||||||
|
|
||||||
Push the image to a registry your hosts pull from, and name it there by digest.
|
Push the image to a registry your hosts pull from, and name it there by digest.
|
||||||
|
|
||||||
The image runs `rproxy` by default. To run `servewaf` from the same image, set
|
The image runs `rproxy` by default. To run `discriminator` from the same image,
|
||||||
the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check
|
set the container's `SWW_COMMAND` to `discriminator`. The image's health check
|
||||||
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
|
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
|
||||||
the app accepts connections, whether or not `servewaf` answers; in `servewaf`'s
|
the app accepts connections, whether or not `discriminator` answers; in
|
||||||
container, once `servewaf` answers.
|
`discriminator`'s container, once `discriminator` answers.
|
||||||
|
|
||||||
### The usual setup: three containers
|
### The usual setup: three containers
|
||||||
|
|
||||||
@@ -91,24 +92,24 @@ The usual setup is three containers on one private network:
|
|||||||
|
|
||||||
- the app;
|
- the app;
|
||||||
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
||||||
names `servewaf` and `SWRP_UPSTREAM_URL` names the app;
|
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
|
||||||
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at
|
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
|
||||||
`/var/lib/smallwebwaf` for its state files.
|
`/var/lib/sww` for its state files.
|
||||||
|
|
||||||
Point traefik's labels at port 8080 of `rproxy`
|
Point traefik's labels at port 8080 of `rproxy`
|
||||||
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
||||||
of the app or of `servewaf`. Anything on that network can reach the app around
|
of the app or of `discriminator`. Anything on that network can reach the app
|
||||||
`rproxy`, and can ask `servewaf` questions, so the network holds only what you
|
around `rproxy`, and can ask `discriminator` questions, so the network holds
|
||||||
trust. Each further app adds its own container and its own `rproxy`, whose
|
only what you trust. Each further app adds its own container and its own
|
||||||
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" and "In front
|
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
|
||||||
of gitea" examples below show this shape.
|
setup" example below shows this shape.
|
||||||
|
|
||||||
### `rproxy` in the app's container
|
### `rproxy` in the app's container
|
||||||
|
|
||||||
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
|
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
|
||||||
runs beside the app in one container. `servewaf` still runs in a container of
|
runs beside the app in one container. `discriminator` still runs in a container
|
||||||
its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by
|
of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
|
||||||
digest, and adds:
|
by digest, and adds:
|
||||||
|
|
||||||
- the app's binary;
|
- the app's binary;
|
||||||
- the packages the app needs, from the nixpkgs in the image;
|
- the packages the app needs, from the nixpkgs in the image;
|
||||||
@@ -168,16 +169,16 @@ does.
|
|||||||
- A log line with `"msg":"starting"` gives the version, the address and every
|
- A log line with `"msg":"starting"` gives the version, the address and every
|
||||||
setting in effect, with secrets masked.
|
setting in effect, with secrets masked.
|
||||||
- A request to the site through traefik gets the app's answer.
|
- A request to the site through traefik gets the app's answer.
|
||||||
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines
|
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
|
||||||
about the request have `"action":"forward"`.
|
the request have `"action":"forward"`.
|
||||||
- To see a refusal without banning yourself, run `servewaf` with
|
- To see a refusal without banning yourself, run `discriminator` with
|
||||||
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
|
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||||
`servewaf`'s log lines have `"would_action":"banned"` and
|
`discriminator`'s log lines have `"would_action":"temporarily_banned"` and
|
||||||
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
||||||
"Clients and bans" below describes.
|
"Clients and bans" below describes.
|
||||||
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||||
`servewaf`'s network:
|
`discriminator`'s network:
|
||||||
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`.
|
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
|
||||||
- `make example-app` builds the image and an app on it, runs them, and checks
|
- `make example-app` builds the image and an app on it, runs them, and checks
|
||||||
the whole exchange (see "Entrypoints").
|
the whole exchange (see "Entrypoints").
|
||||||
|
|
||||||
@@ -185,16 +186,17 @@ does.
|
|||||||
|
|
||||||
Every setting is an environment variable, read once at start; changing one means
|
Every setting is an environment variable, read once at start; changing one means
|
||||||
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
||||||
`servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
||||||
default, chosen for a service on the open internet. A setting that needs an
|
default, chosen for a service on the open internet. A setting that needs an
|
||||||
account, a key, a token or an alert destination stays off until you give one.
|
account, a key, a token or an alert destination stays off until you give one.
|
||||||
|
|
||||||
- Any `SWWAF_` setting can be given as a file instead, for secrets and long
|
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
|
||||||
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
|
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
|
||||||
The file's contents are the value, less one newline at the end. Setting both
|
contents are the value, less one newline at the end. Setting both forms stops
|
||||||
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` and
|
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
|
||||||
`SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
|
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
|
||||||
`_FILE` form. No `SWRP_` setting has one, since `rproxy` never touches disk.
|
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
|
||||||
|
one, since `rproxy` never touches disk.
|
||||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
||||||
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
||||||
1024: `512K`, `100M`, `5G`.
|
1024: `512K`, `100M`, `5G`.
|
||||||
@@ -208,128 +210,133 @@ account, a key, a token or an alert destination stays off until you give one.
|
|||||||
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
||||||
- `off` switches a limit, a threshold or a timeout off where the table says a
|
- `off` switches a limit, a threshold or a timeout off where the table says a
|
||||||
setting takes it.
|
setting takes it.
|
||||||
|
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
||||||
|
app's response bodies, the visitors' request bodies, or both, as
|
||||||
|
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
|
||||||
|
a WebSocket counts as request bytes, and what the app sent as response bytes.
|
||||||
- An invalid setting stops the process at start with one log line naming the
|
- An invalid setting stops the process at start with one log line naming the
|
||||||
variable and what is wrong with the value. The settings in effect are logged
|
variable and what is wrong with the value. The settings in effect are logged
|
||||||
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
||||||
|
|
||||||
Every setting the code reads, with its default:
|
Every setting the code reads, with its default:
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. |
|
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
||||||
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf:8080`. |
|
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
||||||
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||||
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||||
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
||||||
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `servewaf`'s answer to a question and to a report, in milliseconds. |
|
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
|
||||||
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||||
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||||
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
|
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
|
||||||
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
|
||||||
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
||||||
| `SWWAF_LISTEN_ADDR` | `:8080` | Where `servewaf` listens; leave the host part empty. |
|
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
||||||
| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `servewaf` logs, and in every alert and metric, such as `fsn1/servewaf`. |
|
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
|
||||||
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||||
| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||||
| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||||
| `SWWAF_MAX_BANS` | `5000` | The most bans `servewaf` keeps of its own; bans an admin made do not count. |
|
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
||||||
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||||
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
||||||
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||||
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
|
||||||
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
|
||||||
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
|
||||||
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
|
||||||
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||||
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||||
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||||
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
|
||||||
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
|
||||||
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||||
| `SWWAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||||
| `SWWAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
|
||||||
| `SWWAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||||
| `SWWAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||||
| `SWWAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||||
| `SWWAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
|
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
||||||
| `SWWAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||||
| `SWWAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
||||||
| `SWWAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||||
| `SWWAF_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
||||||
| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||||
| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
||||||
| `SWWAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWWAF_ASN_LIMIT_PERCENT`. |
|
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
|
||||||
| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. |
|
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
||||||
| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||||
| `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||||
| `SWWAF_RULES_DIR` | `/etc/smallwebwaf/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
|
||||||
| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||||
| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||||
| `SWWAF_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||||
| `SWWAF_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
||||||
| `SWWAF_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
||||||
| `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
||||||
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
||||||
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||||
| `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||||
| `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
||||||
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||||
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||||
| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||||
| `SWWAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||||
| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
||||||
| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||||
| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||||
| `SWWAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||||
| `SWWAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
||||||
| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
||||||
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
||||||
| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||||
| `SWWAF_STATE_DIR` | `/var/lib/smallwebwaf` | The directory of the state files, an absolute path. |
|
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||||
| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||||
| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||||
| `SWWAF_LOG_LEVEL` | `info` | The least severe of `servewaf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||||
| `SWWAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||||
| `SWWAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `servewaf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
||||||
| `SWWAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||||
| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
||||||
| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||||
| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||||
| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||||
| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_smallwebwaf/metrics`, at least 32 characters; unset, the metrics are off. |
|
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||||
| `SWWAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||||
| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
||||||
| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||||
| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||||
| `SWWAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||||
| `SWWAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
||||||
| `SWWAF_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||||
| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
||||||
| `SWWAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||||
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
||||||
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||||
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||||
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
|
||||||
| `SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
|
||||||
| `SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||||
| `SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||||
| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
|
||||||
| `SWWAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
|
||||||
| `SWWAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||||
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||||
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||||
| `SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||||
| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
|
||||||
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
|
||||||
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||||
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||||
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
|
||||||
| `SWWAF_WATCH_NETS` | empty | Named netblocks the `SWWAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
|
||||||
| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||||
| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||||
| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||||
| `SWWAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
|
||||||
|
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
|
||||||
|
|
||||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||||
full, and the limits that are fixed rather than settings.
|
full, and the limits that are fixed rather than settings.
|
||||||
@@ -339,14 +346,13 @@ full, and the limits that are fixed rather than settings.
|
|||||||
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
|
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
|
||||||
`make docker` gives the image; on another host, use the name you pushed it
|
`make docker` gives the image; on another host, use the name you pushed it
|
||||||
under, by digest. Docker Compose puts the services on a private network of its
|
under, by digest. Docker Compose puts the services on a private network of its
|
||||||
own, where each reaches the others by service name. Only "In front of gitea"
|
own, where each reaches the others by service name. Each example publishes
|
||||||
uses traefik. The other three publish `rproxy`'s port on the host, for trying it
|
`rproxy`'s port on the host, for trying it out.
|
||||||
out.
|
|
||||||
|
|
||||||
### Smallest setup
|
### Smallest setup
|
||||||
|
|
||||||
One app, `rproxy` in front of it, and `servewaf` with a volume for its state
|
One app, `rproxy` in front of it, and `discriminator` with a volume for its
|
||||||
files, every setting at its default.
|
state files, every setting at its default.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -357,20 +363,20 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||||
SWRP_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
servewaf:
|
discriminator:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SMALLWEBWAF_COMMAND: servewaf
|
SWW_COMMAND: discriminator
|
||||||
volumes:
|
volumes:
|
||||||
- servewaf-state:/var/lib/smallwebwaf
|
- discriminator-state:/var/lib/sww
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
servewaf-state:
|
discriminator-state:
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom per-address limits
|
### Custom per-address limits
|
||||||
@@ -393,128 +399,45 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||||
SWRP_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
servewaf:
|
discriminator:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SMALLWEBWAF_COMMAND: servewaf
|
SWW_COMMAND: discriminator
|
||||||
# Monitoring skips every check.
|
# Monitoring skips every check.
|
||||||
SWWAF_ALLOW_NETS: 192.0.2.10
|
SWW_ALLOW_NETS: 192.0.2.10
|
||||||
# The office and the automation host are never rate limited.
|
# The office and the automation host are never rate limited.
|
||||||
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||||
# A netblock that sends nothing wanted.
|
# A netblock that sends nothing wanted.
|
||||||
SWWAF_DENY_NETS: 198.51.100.128/25
|
SWW_DENY_NETS: 198.51.100.128/25
|
||||||
# Tighter limits for everyone else: a small static site.
|
# Tighter limits for everyone else: a small static site.
|
||||||
SWWAF_RATE_LIMIT_PER_MINUTE: "60"
|
SWW_RATE_LIMIT_PER_MINUTE: "60"
|
||||||
SWWAF_RATE_LIMIT_PER_HOUR: "600"
|
SWW_RATE_LIMIT_PER_HOUR: "600"
|
||||||
SWWAF_RATE_LIMIT_PER_DAY: "3000"
|
SWW_RATE_LIMIT_PER_DAY: "3000"
|
||||||
# Hosting networks and one country get a share of those limits.
|
# Hosting networks and one country get a share of those limits.
|
||||||
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
|
||||||
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
|
|
||||||
# Paths only scanners ask for; one request bans the client.
|
# Paths only scanners ask for; one request bans the client.
|
||||||
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||||
# Answer a refused client with 429 rather than 403.
|
# Answer a refused client with 429 rather than 403.
|
||||||
SWWAF_BAN_RESPONSE: "429"
|
SWW_BAN_RESPONSE: "429"
|
||||||
SWWAF_LIMIT_BAN_DURATION: 30m
|
SWW_LIMIT_BAN_DURATION: 30m
|
||||||
volumes:
|
volumes:
|
||||||
- servewaf-state:/var/lib/smallwebwaf
|
- discriminator-state:/var/lib/sww
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
servewaf-state:
|
discriminator-state:
|
||||||
```
|
```
|
||||||
|
|
||||||
### In front of gitea
|
|
||||||
|
|
||||||
gitea behind traefik, set up for a code forge:
|
|
||||||
|
|
||||||
- the upload limits raised, in size and in time;
|
|
||||||
- the response size limit raised, for clones over 1 GiB;
|
|
||||||
- the Actions runners exempt from counting;
|
|
||||||
- the default request and byte limits kept.
|
|
||||||
|
|
||||||
`traefik` is the network traefik is on, made outside this file.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
gitea:
|
|
||||||
# gitea 1.27.3, 2026-08-29
|
|
||||||
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
|
|
||||||
environment:
|
|
||||||
GITEA__server__ROOT_URL: https://git.example/
|
|
||||||
# The host and the port of the SSH clone URLs gitea shows.
|
|
||||||
GITEA__server__SSH_DOMAIN: git.example
|
|
||||||
GITEA__server__SSH_PORT: "2222"
|
|
||||||
# gitea's TCP peer is rproxy, on the private network.
|
|
||||||
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
|
||||||
volumes:
|
|
||||||
- gitea-data:/data
|
|
||||||
networks:
|
|
||||||
- site
|
|
||||||
ports:
|
|
||||||
# SSH does not pass through smallwebwaf.
|
|
||||||
- "2222:22"
|
|
||||||
|
|
||||||
rproxy:
|
|
||||||
image: smallwebwaf
|
|
||||||
environment:
|
|
||||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
|
||||||
SWRP_UPSTREAM_URL: http://gitea:3000
|
|
||||||
# Pushes, LFS objects and image layers over 100 MiB, or slower
|
|
||||||
# than a minute, are cut off at the defaults.
|
|
||||||
SWRP_MAX_REQUEST_SIZE: 2G
|
|
||||||
SWRP_MAX_REQUEST_TIME_MS: "600000"
|
|
||||||
# A clone larger than 1 GiB is cut off at the default.
|
|
||||||
SWRP_MAX_RESPONSE_SIZE: 4G
|
|
||||||
networks:
|
|
||||||
- site
|
|
||||||
- traefik
|
|
||||||
labels:
|
|
||||||
traefik.enable: "true"
|
|
||||||
# rproxy is on two networks; traefik reaches it on this one.
|
|
||||||
traefik.docker.network: traefik
|
|
||||||
traefik.http.routers.git.rule: Host(`git.example`)
|
|
||||||
traefik.http.routers.git.entrypoints: websecure
|
|
||||||
traefik.http.routers.git.tls.certresolver: letsencrypt
|
|
||||||
traefik.http.services.git.loadbalancer.server.port: "8080"
|
|
||||||
|
|
||||||
servewaf:
|
|
||||||
image: smallwebwaf
|
|
||||||
environment:
|
|
||||||
SMALLWEBWAF_COMMAND: servewaf
|
|
||||||
SWWAF_INSTANCE_NAME: fsn1/servewaf
|
|
||||||
# The Actions runners ask for work all day; never count them.
|
|
||||||
SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24
|
|
||||||
# gitea never serves these; a request for one bans the client.
|
|
||||||
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
|
|
||||||
volumes:
|
|
||||||
- servewaf-state:/var/lib/smallwebwaf
|
|
||||||
networks:
|
|
||||||
- site
|
|
||||||
|
|
||||||
networks:
|
|
||||||
site:
|
|
||||||
traefik:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
gitea-data:
|
|
||||||
servewaf-state:
|
|
||||||
```
|
|
||||||
|
|
||||||
`deploy/gitea` holds a complete setup of gitea, traefik included, as a Docker
|
|
||||||
Compose file. [`docs/gitea.md`](docs/gitea.md) explains it, and records what the
|
|
||||||
defaults do in front of gitea, request by request.
|
|
||||||
|
|
||||||
### `rproxy` beside the app
|
### `rproxy` beside the app
|
||||||
|
|
||||||
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
|
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
|
||||||
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
|
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
|
||||||
container". `rproxy` answers `503` to every request while `servewaf` is down,
|
container". `rproxy` answers `503` to every request while `discriminator` is
|
||||||
and `servewaf`:
|
down, and `discriminator`:
|
||||||
|
|
||||||
- observes before it enforces;
|
- observes before it enforces;
|
||||||
- looks addresses up in a local database rather than GeoJS;
|
- looks addresses up in a local database rather than GeoJS;
|
||||||
@@ -527,46 +450,46 @@ services:
|
|||||||
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
||||||
build: ./app
|
build: ./app
|
||||||
environment:
|
environment:
|
||||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||||
# Answer 503 rather than pass a request on while servewaf is down.
|
# Answer 503 rather than pass a request on while discriminator is down.
|
||||||
SWRP_DEFAULT_DISPOSITION: deny
|
SWRP_DEFAULT_DISPOSITION: deny
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
servewaf:
|
discriminator:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SMALLWEBWAF_COMMAND: servewaf
|
SWW_COMMAND: discriminator
|
||||||
SWWAF_INSTANCE_NAME: fsn1/servewaf
|
SWW_INSTANCE_NAME: fsn1/discriminator
|
||||||
# Refuse nothing yet; log what enforce mode would have done.
|
# Refuse nothing yet; log what enforce mode would have done.
|
||||||
SWWAF_MODE: observe
|
SWW_MODE: observe
|
||||||
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
||||||
SWWAF_LOOKUP_SOURCE: file
|
SWW_LOOKUP_SOURCE: file
|
||||||
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
||||||
# Alerts to an ntfy topic, with its token read from a file.
|
# Alerts to an ntfy topic, with its token read from a file.
|
||||||
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
||||||
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy
|
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
||||||
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
|
||||||
volumes:
|
volumes:
|
||||||
- servewaf-state:/var/lib/smallwebwaf
|
- discriminator-state:/var/lib/sww
|
||||||
- /srv/ipinfo:/var/lib/ipinfo:ro
|
- /srv/ipinfo:/var/lib/ipinfo:ro
|
||||||
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro
|
- /srv/sww/tokens:/etc/sww/tokens:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
servewaf-state:
|
discriminator-state:
|
||||||
```
|
```
|
||||||
|
|
||||||
The database is mounted by its directory, not by file, so that a replaced
|
The database is mounted by its directory, not by file, so that a replaced
|
||||||
database is seen. A token file belongs to uid 65532, the `smallwebwaf` user,
|
database is seen. A token file belongs to uid 65532, the `sww` user, with mode
|
||||||
with mode `0400`. This one holds an access token the ntfy server issued, which
|
`0400`. This one holds an access token the ntfy server issued, which
|
||||||
`ntfy token add <user>` prints there:
|
`ntfy token add <user>` prints there:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir -p /srv/smallwebwaf/tokens
|
mkdir -p /srv/sww/tokens
|
||||||
# <token> is the access token that ntfy printed.
|
# <token> is the access token that ntfy printed.
|
||||||
echo '<token>' > /srv/smallwebwaf/tokens/ntfy
|
echo '<token>' > /srv/sww/tokens/ntfy
|
||||||
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy
|
chown 65532:65532 /srv/sww/tokens/ntfy
|
||||||
chmod 0400 /srv/smallwebwaf/tokens/ntfy
|
chmod 0400 /srv/sww/tokens/ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
@@ -575,54 +498,58 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
|
|||||||
|
|
||||||
### What happens to a request
|
### What happens to a request
|
||||||
|
|
||||||
A request goes through these steps, in this order. Once `servewaf` refuses a
|
A request goes through these steps, in this order. Once `discriminator` refuses
|
||||||
request, the steps after that are skipped, apart from the report, and nothing of
|
a request, the steps after that are skipped, apart from the report, and nothing
|
||||||
the request reaches the app.
|
of the request reaches the app.
|
||||||
|
|
||||||
`rproxy` takes the first two:
|
`rproxy` takes the first two:
|
||||||
|
|
||||||
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
|
1. The health check, `GET /_sww/healthz`, is answered at once.
|
||||||
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
||||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `servewaf` the request as the
|
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
|
||||||
visitor sent it, with the first 64 KiB of its body.
|
the visitor sent it, with the first 64 KiB of its body.
|
||||||
|
|
||||||
`servewaf` takes the checks:
|
`discriminator` takes the checks:
|
||||||
|
|
||||||
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`.
|
3. The tag lists.
|
||||||
4. The bans.
|
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||||
5. The lookup of the client's AS number and country.
|
5. The bans.
|
||||||
6. The country lists.
|
6. The lookup of the client's AS number and country.
|
||||||
7. The reputation sources.
|
7. The country list.
|
||||||
8. The client's limit percentage, from its AS number, its country or a
|
8. The reputation sources.
|
||||||
reputation hit.
|
9. The tag refusals of `SWW_TAG_DENY`.
|
||||||
9. The rate limits.
|
10. The client's limit percentage, from its tags, its AS number, its country or
|
||||||
10. The trap paths.
|
a reputation hit.
|
||||||
11. The rule files.
|
11. The rate limits.
|
||||||
12. The header limits, and then the Core Rule Set.
|
12. The trap paths.
|
||||||
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
|
13. The rule files.
|
||||||
that prefix.
|
14. The header limits, and then the Core Rule Set.
|
||||||
|
15. The tag refusals again, so that `exploitsignature` can refuse too.
|
||||||
|
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||||
|
prefix.
|
||||||
|
|
||||||
`rproxy` takes the rest, with `servewaf` counting at the end:
|
`rproxy` takes the rest, with `discriminator` counting at the end:
|
||||||
|
|
||||||
14. The request is passed to the app, streaming.
|
17. The request is passed to the app, streaming.
|
||||||
15. After the answer, `rproxy` sends `servewaf` a report with the status and the
|
18. After the answer, `rproxy` sends `discriminator` a report with the status
|
||||||
bytes each way. For an allowed request, `servewaf` counts the bytes toward
|
and the body bytes each way. For an allowed request, `discriminator` counts
|
||||||
the byte limits and adds the request to the client's history.
|
the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if
|
||||||
|
they take it past one, and adds the request to the client's history.
|
||||||
|
|
||||||
A refused visitor is answered with `SWWAF_BAN_RESPONSE`. A `block` rule or a
|
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||||
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||||
false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD`
|
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
||||||
instead. A request over the header limits of step 12 is answered `431`, and bans
|
request over the header limits of step 14 is answered `431`, and bans nobody
|
||||||
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
|
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
||||||
and the status for a size or time limit, such as `413` for a body too large.
|
status for a size or time limit, such as `413` for a body too large.
|
||||||
|
|
||||||
### Clients and bans
|
### Clients and bans
|
||||||
|
|
||||||
- A client is one IPv4 address or one IPv6 netblock of
|
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
|
||||||
`SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
|
since one abuser usually controls a whole /64.
|
||||||
- Each limit is counted over a sliding window with a few integers per client.
|
- Each limit is counted over a sliding window with a few integers per client.
|
||||||
The table of clients and the table of bans each have a bound
|
The table of clients and the table of bans each have a bound
|
||||||
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too.
|
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
|
||||||
- A ban refuses every request from its netblock until it ends. Requests refused
|
- A ban refuses every request from its netblock until it ends. Requests refused
|
||||||
under a ban count toward no limit. A permanent ban ends only when an admin
|
under a ban count toward no limit. A permanent ban ends only when an admin
|
||||||
lifts it.
|
lifts it.
|
||||||
@@ -631,31 +558,54 @@ and the status for a size or time limit, such as `413` for a body too large.
|
|||||||
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
||||||
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
||||||
|
|
||||||
|
### Request tags
|
||||||
|
|
||||||
|
Each request gets tags from what `discriminator` knows of its client, written in
|
||||||
|
the `tags` field of its log line:
|
||||||
|
|
||||||
|
- `asn=<number>` and `country=<code>`, from the lookup;
|
||||||
|
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
|
||||||
|
or `vpn=mullvad`;
|
||||||
|
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
||||||
|
the client;
|
||||||
|
- `exploitsignature`, when a rule file or the Core Rule Set matched the request;
|
||||||
|
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
|
||||||
|
`SWW_ROUTEWATCH_URL` is set.
|
||||||
|
|
||||||
|
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
|
||||||
|
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||||
|
|
||||||
### Attack detection
|
### Attack detection
|
||||||
|
|
||||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||||
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In
|
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||||
front of a code forge, bodies carry what people write and publish, which the
|
front of a code forge, bodies carry what people write and publish, which the
|
||||||
rules take for attacks.
|
rules take for attacks.
|
||||||
- `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
|
- `smallwebwaf` changes the Core Rule Set in nine ways so that ordinary requests
|
||||||
requests to a code forge through, such as git over HTTP and sign-in through
|
get through to a code forge and to WordPress. They include git over HTTP and
|
||||||
Git Credential Manager. On gitea's own paths, its searches, file names and
|
sign-in through Git Credential Manager on a code forge, and saving a post from
|
||||||
branch names get through too. No setting undoes the changes; "Attack
|
the block editor on WordPress. On gitea's own paths, its searches, file names
|
||||||
detection" in [`SPEC.md`](SPEC.md) lists them.
|
and branch names get through too. On WordPress's own paths, its searches and
|
||||||
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
|
file names do. No setting undoes the changes; "Attack detection" in
|
||||||
own fields or paths.
|
[`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
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||||
while `servewaf` runs. The image ships one file of probes no real visitor
|
while `discriminator` runs. The image ships one file of probes no real visitor
|
||||||
sends (see [`docs/rules.md`](docs/rules.md)).
|
sends (see [`docs/rules.md`](docs/rules.md)).
|
||||||
|
|
||||||
### State files
|
### State files
|
||||||
|
|
||||||
`servewaf` keeps everything in memory, and the five JSON files in
|
`discriminator` keeps everything in memory, and the five JSON files in
|
||||||
`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
||||||
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
||||||
renamed over the real one, so a crash leaves a complete file. `servewaf` notices
|
renamed over the real one, so a crash leaves a complete file. `discriminator`
|
||||||
a file edited on disk and takes the edit in; an edit that does not parse is set
|
notices a file edited on disk and takes the edit in; an edit that does not parse
|
||||||
aside as `<name>.bad`, and never stops the running process (see
|
is set aside as `<name>.bad`, and never stops the running process (see
|
||||||
[`docs/state-files.md`](docs/state-files.md)).
|
[`docs/state-files.md`](docs/state-files.md)).
|
||||||
|
|
||||||
### The image and its processes
|
### The image and its processes
|
||||||
@@ -664,33 +614,34 @@ aside as `<name>.bad`, and never stops the running process (see
|
|||||||
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
||||||
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
||||||
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
||||||
- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid
|
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
|
||||||
and gid 65532) and starts it again when it exits. The `run` script of
|
and starts it again when it exits. The `run` script of `discriminator` gives
|
||||||
`servewaf` gives the state directory to the `smallwebwaf` user at each start,
|
the state directory to the `sww` user at each start, so a volume needs no
|
||||||
so a volume needs no change of owner.
|
change of owner.
|
||||||
- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints,
|
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own
|
||||||
under `/_smallwebwaf/`, are for your admin tools and your metrics collector,
|
endpoints, under `/_sww/`, are for your admin tools and your metrics
|
||||||
at its own address on the private network; nothing routes the internet there.
|
collector, at its own address on the private network; nothing routes the
|
||||||
|
internet there.
|
||||||
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
|
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
|
||||||
in the app's container, the app must keep answering for five seconds after
|
in the app's container, the app must keep answering for five seconds after
|
||||||
`SIGTERM` for that to help them.
|
`SIGTERM` for that to help them.
|
||||||
- Only faults found at start stop a process: an invalid setting, a rule file or
|
- Only faults found at start stop a process: an invalid setting, a rule file or
|
||||||
state file that does not parse, a lookup database that cannot be read, or an
|
state file that does not parse, a lookup database that cannot be read, or an
|
||||||
address it cannot listen on. Once running, a failing lookup service,
|
address it cannot listen on. Once running, a failing lookup service,
|
||||||
reputation source or alert destination never takes `servewaf` down.
|
reputation source or alert destination never takes `discriminator` down.
|
||||||
|
|
||||||
### Packages
|
### Packages
|
||||||
|
|
||||||
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
- `cmd/sww`: the binary, which only calls `internal/sww`.
|
||||||
- `internal/smallwebwaf`: the process, as the command it is given, and the
|
- `internal/sww`: the process, as the command it is given, and the health check.
|
||||||
health check.
|
|
||||||
- `internal/config`: reads the settings, the one place they are read.
|
- `internal/config`: reads the settings, the one place they are read.
|
||||||
- `internal/rproxy`: the reverse proxy in front of one app.
|
- `internal/rproxy`: the reverse proxy in front of one app.
|
||||||
- `internal/servewaf`: `servewaf`'s server, which runs the checks and answers
|
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
|
||||||
its own endpoints.
|
answers its own endpoints.
|
||||||
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
- `internal/ratelimit`, `internal/bans`, `internal/rules`,
|
||||||
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
|
`internal/coreruleset`, `internal/lookup`, `internal/reputation`,
|
||||||
`internal/alerts`: one check or source each.
|
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
|
||||||
|
source each.
|
||||||
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
||||||
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
||||||
metrics.
|
metrics.
|
||||||
@@ -747,46 +698,42 @@ on the host.
|
|||||||
`make check`.
|
`make check`.
|
||||||
- `script/docker`: builds the image, whose build runs the tests and the linter
|
- `script/docker`: builds the image, whose build runs the tests and the linter
|
||||||
first. `make docker`.
|
first. `make docker`.
|
||||||
|
- `script/publish`: runs `script/docker`, whose build happens on the Docker host
|
||||||
|
named by `DOCKER_HOST` (`ssh://root@fsn1app1` unless set). It then pushes the
|
||||||
|
image to `PUBLISH_IMAGE` (`git.eeqj.de/sneak/smallwebwaf` unless set). It
|
||||||
|
refuses a working tree with uncommitted changes. `make publish`. It pushes
|
||||||
|
four tags:
|
||||||
|
- the UTC date as `YYYY-MM-DD`;
|
||||||
|
- the short commit hash;
|
||||||
|
- the branch name;
|
||||||
|
- `latest`.
|
||||||
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
||||||
image build.
|
image build.
|
||||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||||
- `script/install-precommit`: installs that hook. `make hooks`.
|
- `script/install-precommit`: installs that hook. `make hooks`.
|
||||||
- `script/build`: builds `bin/smallwebwaf` with the Go on your host.
|
- `script/build`: builds `bin/sww` with the Go on your host. `make build`.
|
||||||
`make build`.
|
- `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
|
||||||
- `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the
|
|
||||||
host, with the settings in your environment. `make run`. Unless your settings
|
host, with the settings in your environment. `make run`. Unless your settings
|
||||||
say otherwise:
|
say otherwise:
|
||||||
- `rproxy` listens on port 8080, in front of an app at
|
- `rproxy` listens on port 8080, in front of an app at
|
||||||
`http://127.0.0.1:8081`;
|
`http://127.0.0.1:8081`;
|
||||||
- `servewaf` listens on port 8090;
|
- `discriminator` listens on port 8090;
|
||||||
- its state files go in `bin/state`;
|
- its state files go in `bin/state`;
|
||||||
- its rule files are those of `share/rules.d`.
|
- its rule files are those of `share/rules.d`.
|
||||||
- `script/example-app`: builds the image and the example app
|
- `script/example-app`: builds the image and the example app
|
||||||
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
||||||
`servewaf` in another, and checks them. Then it removes the containers, the
|
`discriminator` in another, and checks them. Then it removes the containers,
|
||||||
network, the volumes and the images. Needs network access. `make example-app`.
|
the network, the volumes and the images. Needs network access.
|
||||||
It checks:
|
`make example-app`. It checks:
|
||||||
- the health checks;
|
- the health checks;
|
||||||
- a request passed to the app;
|
- a request passed to the app;
|
||||||
- a ban for a broken limit, and a ban for a probe made permanent;
|
- a ban for a broken limit, and a ban for a probe made permanent;
|
||||||
- that only `servewaf`'s container changes the state directory's owner;
|
- that only `discriminator`'s container changes the state directory's owner;
|
||||||
- with `servewaf` stopped, the app still answering through `rproxy`;
|
- with `discriminator` stopped, the app still answering through `rproxy`;
|
||||||
- with `servewaf` started again, bans applying again;
|
- with `discriminator` started again, bans applying again;
|
||||||
- orderly stops with `sv stop` and `docker stop`;
|
- orderly stops with `sv stop` and `docker stop`;
|
||||||
- bans surviving a new container on the same volume;
|
- bans surviving a new container on the same volume;
|
||||||
- a setting given as a file.
|
- a setting given as a file.
|
||||||
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind
|
|
||||||
`rproxy` and `servewaf`, three containers on one network. Every setting is at
|
|
||||||
its default, but for those that join the containers and
|
|
||||||
`SWWAF_LOG_REQUEST_HEADERS`, which adds the header that labels each request.
|
|
||||||
It sends through them a sample of gitea's ordinary use and the requests the
|
|
||||||
Core Rule Set is known to refuse, and prints what `servewaf` did with each.
|
|
||||||
Needs network access, and a docker daemon that pushes to a registry on
|
|
||||||
`localhost` over plain HTTP. `make gitea-requests`.
|
|
||||||
- `script/gitea-setup`: builds the image, brings up the setup in `deploy/gitea`,
|
|
||||||
and checks it as [`docs/gitea.md`](docs/gitea.md) describes. Then it removes
|
|
||||||
the containers, the network, the volumes and the image it built. Needs network
|
|
||||||
access. `make gitea-setup`.
|
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
@@ -802,7 +749,7 @@ on the host.
|
|||||||
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
|
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
|
||||||
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
|
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
|
||||||
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
||||||
`/_smallwebwaf/`, with examples.
|
`/_sww/`, with examples.
|
||||||
- [`docs/request-log.md`](docs/request-log.md): the request log's fields.
|
- [`docs/request-log.md`](docs/request-log.md): the request log's fields.
|
||||||
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
||||||
gets.
|
gets.
|
||||||
@@ -810,8 +757,6 @@ on the host.
|
|||||||
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
||||||
holds.
|
holds.
|
||||||
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
||||||
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea: the setup
|
|
||||||
in `deploy/gitea`, and what the defaults do there.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// Command smallwebwaf is a web application firewall. `smallwebwaf rproxy` is
|
|
||||||
// the reverse proxy in front of one app, which asks servewaf about each request.
|
|
||||||
// `smallwebwaf servewaf` decides, for many sites, and keeps what lasts.
|
|
||||||
// `smallwebwaf healthcheck` is the container's health check. Everything it does
|
|
||||||
// is in internal/smallwebwaf.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Version is the version of the binary, set when it is built with
|
|
||||||
// -ldflags "-X main.Version=...".
|
|
||||||
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
os.Exit(smallwebwaf.Main(Version))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Command sww is a web application firewall. `sww rproxy` is the reverse
|
||||||
|
// proxy in front of one app, which asks discriminator about each request.
|
||||||
|
// `sww discriminator` decides, for many sites, and keeps what lasts. `sww
|
||||||
|
// healthcheck` is the container's health check. Everything it does is in
|
||||||
|
// internal/sww.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the version of the binary, set when it is built with
|
||||||
|
// -ldflags "-X main.Version=...".
|
||||||
|
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(sww.Main(Version))
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
# A real app names the smallwebwaf image by digest. This one takes the
|
# A real app names the smallwebwaf image by digest. This one takes the
|
||||||
# image script/example-app has just built, or else the one `make docker`
|
# image script/example-app has just built, or else the one `make docker`
|
||||||
# builds.
|
# builds.
|
||||||
ARG SMALLWEBWAF_IMAGE=smallwebwaf
|
ARG SWW_IMAGE=smallwebwaf
|
||||||
FROM ${SMALLWEBWAF_IMAGE}
|
FROM ${SWW_IMAGE}
|
||||||
|
|
||||||
# Packages the app needs, from the nixpkgs in the image.
|
# Packages the app needs, from the nixpkgs in the image.
|
||||||
RUN nix-env -iA nixpkgs.busybox
|
RUN nix-env -iA nixpkgs.busybox
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
# Gitea behind traefik, with rproxy and servewaf in front of it.
|
|
||||||
# docs/gitea.md says what each setting here does and why. Before starting it:
|
|
||||||
# - set SMALLWEBWAF_IMAGE to the smallwebwaf image, named by digest;
|
|
||||||
# - replace git.example.org with the site's host name;
|
|
||||||
# - replace each 10.99.0 with the first three numbers of a /24 subnet that
|
|
||||||
# no other network on the host uses. The /24 must lie inside 10.0.0.0/8,
|
|
||||||
# 172.16.0.0/12 or 192.168.0.0/16, because rproxy believes
|
|
||||||
# X-Forwarded-For only from those by default. Replace each fd10:99 with
|
|
||||||
# the first two hextets of an unused /64 subnet whose third and fourth
|
|
||||||
# hextets are 0, as in fd10:99::/64;
|
|
||||||
# - set GITEA_SECRET_KEY to the output of
|
|
||||||
# `docker run --rm <gitea's image below> gitea generate secret SECRET_KEY`,
|
|
||||||
# once per install, and keep it.
|
|
||||||
# Then run `docker compose up --detach` in this directory.
|
|
||||||
# script/gitea-setup checks this setup.
|
|
||||||
|
|
||||||
name: gitea
|
|
||||||
|
|
||||||
x-logging: &logging
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: 10m
|
|
||||||
max-file: "5"
|
|
||||||
|
|
||||||
services:
|
|
||||||
traefik:
|
|
||||||
# traefik v3.7.17, 2026-11-03
|
|
||||||
image: traefik@sha256:68ae5fe5b64ca166704b746a9e8afcd385e36b3f569a5691401bf652d57b3748
|
|
||||||
# traefik replaces the X-Forwarded-For header a visitor sends. Keep it
|
|
||||||
# so: otherwise a visitor could name an address on the shared network,
|
|
||||||
# and skip servewaf's checks.
|
|
||||||
command:
|
|
||||||
- --providers.docker.exposedbydefault=false
|
|
||||||
- --entrypoints.web.address=:80
|
|
||||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
|
||||||
- --entrypoints.websecure.address=:443
|
|
||||||
# Until a certificate resolver is added here, as for your other
|
|
||||||
# sites, traefik answers with a certificate it made itself.
|
|
||||||
- --entrypoints.websecure.http.tls=true
|
|
||||||
# traefik's own limit of 60 seconds for a whole request would cut
|
|
||||||
# off an upload long before rproxy's limit of 30 minutes.
|
|
||||||
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=30m
|
|
||||||
ports:
|
|
||||||
- 80:80
|
|
||||||
- 443:443
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
networks:
|
|
||||||
shared:
|
|
||||||
# Containers on the shared network find traefik under the
|
|
||||||
# site's name, and so reach it from their own address.
|
|
||||||
aliases:
|
|
||||||
- git.example.org
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
gitea:
|
|
||||||
# gitea 1.27.3, 2026-08-29
|
|
||||||
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
|
|
||||||
environment:
|
|
||||||
GITEA__server__ROOT_URL: https://git.example.org/
|
|
||||||
# Gitea starts at its defaults, with SQLite, instead of showing its
|
|
||||||
# install page to whoever opens it first.
|
|
||||||
GITEA__security__INSTALL_LOCK: "true"
|
|
||||||
GITEA__security__SECRET_KEY: ${GITEA_SECRET_KEY:?set GITEA_SECRET_KEY, see docs/gitea.md}
|
|
||||||
# Accounts are made with `gitea admin user create`.
|
|
||||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
|
||||||
# rproxy's addresses. Gitea then takes each visitor's address from
|
|
||||||
# the X-Real-Ip header traefik sets and rproxy passes on.
|
|
||||||
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.99.0.2,fd10:99::2
|
|
||||||
volumes:
|
|
||||||
- gitea-data:/data
|
|
||||||
networks:
|
|
||||||
- shared
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
rproxy:
|
|
||||||
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
|
|
||||||
environment:
|
|
||||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
|
||||||
SWRP_UPSTREAM_URL: http://gitea:3000
|
|
||||||
# Pushes, LFS objects, packages and release attachments of up to
|
|
||||||
# 2 GiB, sent in up to 30 minutes.
|
|
||||||
SWRP_MAX_REQUEST_SIZE: 2G
|
|
||||||
SWRP_MAX_REQUEST_TIME_MS: "1800000"
|
|
||||||
# 15 minutes for a clone or a download, half the default.
|
|
||||||
SWRP_MAX_RESPONSE_TIME_MS: "900000"
|
|
||||||
labels:
|
|
||||||
traefik.enable: "true"
|
|
||||||
traefik.http.routers.gitea.rule: Host(`git.example.org`)
|
|
||||||
traefik.http.routers.gitea.entrypoints: websecure
|
|
||||||
traefik.http.services.gitea.loadbalancer.server.port: "8080"
|
|
||||||
networks:
|
|
||||||
shared:
|
|
||||||
ipv4_address: 10.99.0.2
|
|
||||||
ipv6_address: fd10:99::2
|
|
||||||
logging: *logging
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
servewaf:
|
|
||||||
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
|
|
||||||
environment:
|
|
||||||
SMALLWEBWAF_COMMAND: servewaf
|
|
||||||
# The containers on the shared network skip the checks. Add the
|
|
||||||
# address of an automation fleet on another host here too; without
|
|
||||||
# that, it is counted like any client.
|
|
||||||
SWWAF_ALLOW_NETS: 10.99.0.128/25,fd10:99:0:0:8000::/65
|
|
||||||
# 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
|
# Admin endpoints
|
||||||
|
|
||||||
The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
|
The endpoints under `/_sww/`, what each takes and what each answers.
|
||||||
`smallwebwaf servewaf` answers them at its own address, on the private network.
|
`sww discriminator` answers them at its own address, on the private network.
|
||||||
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||||
|
|
||||||
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
|
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
||||||
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
|
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
|
||||||
[`metrics.md`](metrics.md)).
|
[`metrics.md`](metrics.md)).
|
||||||
- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json`
|
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
||||||
lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below.
|
Needs `SWW_ADMIN_TOKEN`, as do the three below.
|
||||||
- `POST /_smallwebwaf/bans`: bans a netblock. The body is a JSON object of
|
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
||||||
`netblock`, `duration` and, if you like, `reason`. `netblock` is a netblock
|
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
||||||
such as `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
||||||
(`SWWAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
||||||
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
||||||
wrong:
|
wrong:
|
||||||
- one that is not such an object, or has another field;
|
- one that is not such an object, or has another field;
|
||||||
- one longer than 4 KiB;
|
- one longer than 4 KiB;
|
||||||
- one that names an IPv4-mapped netblock, or an address with a zone.
|
- one that names an IPv4-mapped netblock, or an address with a zone.
|
||||||
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the
|
- `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
|
||||||
address `<client>`. It answers `404` when none is active.
|
`<client>`. It answers `404` when none is active.
|
||||||
- `GET /_smallwebwaf/clients/<ip>`: what `servewaf` knows of the client. Under
|
- `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
|
||||||
`client`, its counters and history as `clients.json` holds them, or `null`
|
`client`, its counters and history as `clients.json` holds them, or `null`
|
||||||
when `clients.json` does not hold it. Under `bans`, every ban covering the
|
when `clients.json` does not hold it. Under `bans`, every ban covering the
|
||||||
address, with its notes.
|
address, with its notes.
|
||||||
- `POST /_smallwebwaf/report`: takes `rproxy`'s report, without a token. It
|
- `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers
|
||||||
answers `204`, or `400` to a body that is no report (see "The report" in
|
`204`, or `400` to a body that is no report (see "The report" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
No check runs on these requests. A missing or wrong token is answered `401`. An
|
No check runs on these requests. A missing or wrong token is answered `401`. An
|
||||||
endpoint whose token is unset answers `404`, as does any other path.
|
endpoint whose token is unset answers `404`, as does any other path.
|
||||||
|
|
||||||
`smallwebwaf rproxy` answers only the health check of these, at its own address.
|
`sww rproxy` answers only the health check of these, at its own address. It asks
|
||||||
It asks `servewaf` about any other request under `/_smallwebwaf/`. One that
|
`discriminator` about any other request under `/_sww/`. One that passes the
|
||||||
passes the checks is answered `404`, so that the app never sees that prefix (see
|
checks is answered `404`, so that the app never sees that prefix (see "`rproxy`"
|
||||||
"`rproxy`" in [`SPEC.md`](../SPEC.md)).
|
in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `servewaf` reached as
|
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
|
||||||
`servewaf:8080` on its network:
|
as `discriminator:8080` on its network:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Every ban.
|
# Every ban.
|
||||||
curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/bans
|
curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans
|
||||||
|
|
||||||
# Ban 203.0.113.0/24 for seven days.
|
# Ban 203.0.113.0/24 for seven days.
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
||||||
http://servewaf:8080/_smallwebwaf/bans
|
http://discriminator:8080/_sww/bans
|
||||||
|
|
||||||
# Lift the bans on 203.0.113.9.
|
# Lift the bans on 203.0.113.9.
|
||||||
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
||||||
http://servewaf:8080/_smallwebwaf/bans/203.0.113.9
|
http://discriminator:8080/_sww/bans/203.0.113.9
|
||||||
|
|
||||||
# What servewaf knows of 203.0.113.9.
|
# What discriminator knows of 203.0.113.9.
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
http://servewaf:8080/_smallwebwaf/clients/203.0.113.9
|
http://discriminator:8080/_sww/clients/203.0.113.9
|
||||||
```
|
```
|
||||||
+14
-14
@@ -6,12 +6,12 @@ back or dropped.
|
|||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
- `ban`: a ban `servewaf` made.
|
- `ban`: a ban `discriminator` made.
|
||||||
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
||||||
a request.
|
a request.
|
||||||
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
- `core_rule_set_match`: the Core Rule Set scored a request at or over the
|
||||||
request was refused unless the alert's `detail` gives `mode`: `detect` or
|
threshold. The request was refused unless the alert's `detail` gives `mode`:
|
||||||
`observe`.
|
`detect` or `observe`.
|
||||||
- `anomaly`: a count over an anomaly threshold.
|
- `anomaly`: a count over an anomaly threshold.
|
||||||
- `reputation_hit`: a request from a client on a blocklist, the CrowdSec
|
- `reputation_hit`: a request from a client on a blocklist, the CrowdSec
|
||||||
decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit.
|
decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit.
|
||||||
@@ -34,7 +34,7 @@ on one line:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"instance": "fsn1/servewaf",
|
"instance": "fsn1/discriminator",
|
||||||
"time": "2026-10-06T12:00:50.123461Z",
|
"time": "2026-10-06T12:00:50.123461Z",
|
||||||
"event": "ban",
|
"event": "ban",
|
||||||
"host": "app.example",
|
"host": "app.example",
|
||||||
@@ -77,11 +77,11 @@ on one line:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||||
one of the events above.
|
one of the events above.
|
||||||
- `host`: the site of the request that raised the alert, since one `servewaf`
|
- `host`: the site of the request that raised the alert, since one
|
||||||
serves many sites. It is left out of `source_failure`, `file_error` and
|
`discriminator` serves many sites. It is left out of `source_failure`,
|
||||||
`summary`.
|
`file_error` and `summary`.
|
||||||
- `client`: the address whose request raised the alert. Empty for
|
- `client`: the address whose request raised the alert. Empty for
|
||||||
`source_failure`, `file_error` and `summary`.
|
`source_failure`, `file_error` and `summary`.
|
||||||
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
||||||
@@ -111,8 +111,8 @@ on one line:
|
|||||||
## Slack and ntfy
|
## Slack and ntfy
|
||||||
|
|
||||||
Slack and ntfy get a message with a title and a text. The title is the instance
|
Slack and ntfy get a message with a title and a text. The title is the instance
|
||||||
and the event, such as `fsn1/servewaf: ban`. The text is the `reason`, then one
|
and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
|
||||||
line for each of these that the alert has:
|
one line for each of these that the alert has:
|
||||||
|
|
||||||
- the host, the client, the netblock and the country;
|
- the host, the client, the netblock and the country;
|
||||||
- the file, the source, the error and the mode from `detail`;
|
- the file, the source, the error and the mode from `detail`;
|
||||||
@@ -122,15 +122,15 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
|
|||||||
escaped. ntfy gets the text as the message, with these headers:
|
escaped. ntfy gets the text as the message, with these headers:
|
||||||
|
|
||||||
- `Title`: the title;
|
- `Title`: the title;
|
||||||
- `Authorization`: the token, as `Bearer <token>`, while
|
- `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
|
||||||
`SWWAF_ALERT_NTFY_TOKEN` is set;
|
is set;
|
||||||
- `Priority` and `Tags`: by event, as listed below.
|
- `Priority` and `Tags`: by event, as listed below.
|
||||||
|
|
||||||
The priority and tag by event:
|
The priority and tag by event:
|
||||||
|
|
||||||
- `ban`: priority `default`, tag `no_entry`.
|
- `ban`: priority `default`, tag `no_entry`.
|
||||||
- `permanent_ban`: priority `high`, tag `no_entry`.
|
- `permanent_ban`: priority `high`, tag `no_entry`.
|
||||||
- `waf_block`: priority `default`, tag `shield`.
|
- `core_rule_set_match`: priority `default`, tag `shield`.
|
||||||
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
|
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
|
||||||
- `reputation_hit`: priority `low`, tag `label`.
|
- `reputation_hit`: priority `low`, tag `label`.
|
||||||
- `source_failure` and `file_error`: priority `high`, tag `warning`.
|
- `source_failure` and `file_error`: priority `high`, tag `warning`.
|
||||||
|
|||||||
-340
@@ -1,340 +0,0 @@
|
|||||||
# smallwebwaf in front of gitea
|
|
||||||
|
|
||||||
`deploy/gitea/compose.yaml` runs gitea behind traefik, with `rproxy` and
|
|
||||||
`servewaf` in front of it. This document says what that setup sets and why, and
|
|
||||||
what the defaults would do without it. [`SPEC.md`](../SPEC.md) says how
|
|
||||||
`smallwebwaf` behaves.
|
|
||||||
|
|
||||||
## The setup
|
|
||||||
|
|
||||||
Four containers share one Docker network, the shared network:
|
|
||||||
|
|
||||||
- traefik, which routes the site's HTTPS requests to `rproxy`, and redirects
|
|
||||||
plain HTTP to HTTPS;
|
|
||||||
- `rproxy`, in front of gitea;
|
|
||||||
- gitea 1.27.3, as its image comes;
|
|
||||||
- `servewaf`, with a volume for its state files.
|
|
||||||
|
|
||||||
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.
|
|
||||||
+8
-9
@@ -1,7 +1,7 @@
|
|||||||
# What the image holds
|
# What the image holds
|
||||||
|
|
||||||
The contents of the `smallwebwaf` image, and how each part is pinned. The image
|
The contents of the `smallwebwaf` image, and how each part is pinned. The image
|
||||||
runs `rproxy` by default, or `servewaf` with `SMALLWEBWAF_COMMAND=servewaf`.
|
runs `rproxy` by default, or `discriminator` with `SWW_COMMAND=discriminator`.
|
||||||
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
||||||
is pinned that way.
|
is pinned that way.
|
||||||
|
|
||||||
@@ -15,13 +15,12 @@ is pinned that way.
|
|||||||
Unpacked, nixpkgs takes about 500 MiB.
|
Unpacked, nixpkgs takes about 500 MiB.
|
||||||
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
||||||
at a fixed commit hash.
|
at a fixed commit hash.
|
||||||
- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532).
|
- The `sww` binary, and the user `sww` (uid and gid 65532).
|
||||||
- The service directory `/etc/service/smallwebwaf`, with the `run` script
|
- The service directory `/etc/service/sww`, with the `run` script
|
||||||
`share/smallwebwaf.run`.
|
`share/sww.run`.
|
||||||
- `/var/lib/smallwebwaf` for `servewaf`'s state files, and
|
- `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
|
||||||
`/etc/smallwebwaf/rules.d` with the default rule file
|
the default rule file `share/rules.d/00-default.rules`.
|
||||||
`share/rules.d/00-default.rules`.
|
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
|
||||||
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`.
|
|
||||||
|
|
||||||
## Pins
|
## Pins
|
||||||
|
|
||||||
@@ -33,4 +32,4 @@ is pinned that way.
|
|||||||
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
||||||
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
||||||
build writes.
|
build writes.
|
||||||
- The Go image that builds `smallwebwaf`: by digest.
|
- The Go image that builds `sww`: by digest.
|
||||||
+43
-48
@@ -1,90 +1,85 @@
|
|||||||
# The metrics
|
# The metrics
|
||||||
|
|
||||||
What `GET /_smallwebwaf/metrics` answers with, at `servewaf`'s own address.
|
What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
|
||||||
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
||||||
keeps the `instance` label as `exported_instance` unless the scrape sets
|
keeps the `instance` label as `exported_instance` unless the scrape sets
|
||||||
`honor_labels: true`. `rproxy` has no metrics.
|
`honor_labels: true`. `rproxy` has no metrics.
|
||||||
|
|
||||||
|
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
||||||
|
body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
|
||||||
|
|
||||||
## Traffic
|
## Traffic
|
||||||
|
|
||||||
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
- `sww_requests_total`, `sww_request_bytes_total` and
|
||||||
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by
|
`sww_response_bytes_total`, by `status_class` such as `2xx` and by `action`;
|
||||||
`action`;
|
- `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
|
||||||
- `smallwebwaf_request_duration_seconds` and
|
histograms;
|
||||||
`smallwebwaf_upstream_duration_seconds`, as histograms;
|
- `sww_requests_in_flight`: the questions and requests to its own endpoints that
|
||||||
- `smallwebwaf_requests_in_flight`: the questions and requests to its own
|
`discriminator` is handling.
|
||||||
endpoints that `servewaf` is handling.
|
|
||||||
|
|
||||||
## Limits and bans
|
## Limits and bans
|
||||||
|
|
||||||
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`,
|
- `sww_rate_limit_hits_total`, by `window` and by `kind` (`requests`, `bytes` or
|
||||||
`bytes` or `refusals`);
|
`refusals`);
|
||||||
- `smallwebwaf_offences_total`, by `kind`;
|
- `sww_offences_total`, by `kind`;
|
||||||
- `smallwebwaf_bans_made_total`, by `cause`;
|
- `sww_bans_made_total`, by `cause`;
|
||||||
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
|
- `sww_active_bans` and `sww_permanent_bans`.
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and
|
- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
|
||||||
`smallwebwaf_rules_loaded`;
|
- `sww_core_rule_set_matches_total`, by `mode` and `rule_id`, for the rules that
|
||||||
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that
|
|
||||||
fired.
|
fired.
|
||||||
|
|
||||||
## Countries and AS numbers
|
## Countries and AS numbers
|
||||||
|
|
||||||
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||||
|
|
||||||
- `smallwebwaf_country_requests_total`,
|
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
||||||
`smallwebwaf_country_request_bytes_total`,
|
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
||||||
`smallwebwaf_country_response_bytes_total` and
|
`country`;
|
||||||
`smallwebwaf_country_list_refusals_total`, by `country`;
|
- `sww_asn_requests_total`, `sww_asn_request_bytes_total` and
|
||||||
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and
|
`sww_asn_response_bytes_total`, by `asn`.
|
||||||
`smallwebwaf_asn_response_bytes_total`, by `asn`.
|
|
||||||
|
|
||||||
## Lookups
|
## Lookups
|
||||||
|
|
||||||
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`;
|
- `sww_geojs_requests_total` and `sww_geojs_failures_total`;
|
||||||
- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and
|
- `sww_requests_without_geojs_answer_total`: the requests that needed an answer
|
||||||
went on without it;
|
and went on without it;
|
||||||
- with the file source,
|
- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
|
||||||
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and
|
`sww_lookup_database_read_failures_total`.
|
||||||
`smallwebwaf_lookup_database_read_failures_total`.
|
|
||||||
|
|
||||||
## Reputation sources
|
## Reputation sources
|
||||||
|
|
||||||
By `source`: each list's URL, each zone, and `abuseipdb`:
|
By `source`: each list's URL, each zone, and `abuseipdb`:
|
||||||
|
|
||||||
- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total`
|
- `sww_reputation_hits_total`, `sww_reputation_queries_total` and
|
||||||
and `smallwebwaf_reputation_failures_total`;
|
`sww_reputation_failures_total`;
|
||||||
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list;
|
- `sww_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||||
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB.
|
- `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||||
|
|
||||||
## State files
|
## State files
|
||||||
|
|
||||||
By `file`:
|
By `file`:
|
||||||
|
|
||||||
- `smallwebwaf_state_file_writes_total`,
|
- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
|
||||||
`smallwebwaf_state_file_write_failures_total`,
|
`sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
|
||||||
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
- `sww_state_file_edits_taken_in_total` and
|
||||||
`smallwebwaf_state_file_size_bytes`;
|
`sww_state_file_edits_set_aside_total`;
|
||||||
- `smallwebwaf_state_file_edits_taken_in_total` and
|
- `sww_tracked_clients`: the clients in the table.
|
||||||
`smallwebwaf_state_file_edits_set_aside_total`;
|
|
||||||
- `smallwebwaf_tracked_clients`: the clients in the table.
|
|
||||||
|
|
||||||
## Remote log, alerts and the process
|
## Remote log, alerts and the process
|
||||||
|
|
||||||
- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`,
|
- while a syslog server is set, `sww_remote_log_lines_sent_total`,
|
||||||
`smallwebwaf_remote_log_lines_dropped_total` and
|
`sww_remote_log_lines_dropped_total` and `sww_remote_log_lines_waiting`;
|
||||||
`smallwebwaf_remote_log_buffer_depth`;
|
- by `destination`, `sww_alerts_sent_total`, `sww_alert_send_failures_total`,
|
||||||
- by `destination`, `smallwebwaf_alerts_sent_total`,
|
`sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
|
||||||
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
|
||||||
`smallwebwaf_alerts_dropped_total`;
|
|
||||||
- Go's own `go_` metrics and the process's `process_` metrics.
|
- Go's own `go_` metrics and the process's `process_` metrics.
|
||||||
|
|
||||||
## When a request is counted
|
## When a request is counted
|
||||||
|
|
||||||
`servewaf` counts a denied request at its decision, and a request to its own
|
`discriminator` counts a denied request at its decision, and a request to its
|
||||||
endpoints when it is answered. It counts an allowed request when its report
|
own endpoints when it is answered. It counts an allowed request when its report
|
||||||
comes, with the status, bytes and times the report gives. These are not counted:
|
comes, with the status, bytes and times the report gives. These are not counted:
|
||||||
|
|
||||||
- a report itself;
|
- a report itself;
|
||||||
|
|||||||
+108
-64
@@ -1,32 +1,32 @@
|
|||||||
# The request log's fields
|
# The request log's fields
|
||||||
|
|
||||||
The fields of the JSON objects `rproxy` and `servewaf` write about each request.
|
The fields of the JSON objects `rproxy` and `discriminator` write about each
|
||||||
"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is
|
request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
|
||||||
written and how the log is sent to a syslog server. A field that does not apply
|
is written and how the log is sent to a syslog server. A field that does not
|
||||||
to a request is left out.
|
apply to a request is left out.
|
||||||
|
|
||||||
## `servewaf`'s lines
|
## `discriminator`'s lines
|
||||||
|
|
||||||
`smallwebwaf servewaf` writes two lines for each question it answers, joined by
|
`sww discriminator` writes two lines for each question it answers, joined by
|
||||||
`request_id`. The field `servewaf_line` says which of the two a line is:
|
`request_id`. The field `discriminator_line` says which of the two a line is:
|
||||||
|
|
||||||
- `decision`: the line it writes once it has answered the question;
|
- `decision`: the line it writes once it has answered the question;
|
||||||
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
||||||
known. The repetition is intended.
|
known. The repetition is intended.
|
||||||
|
|
||||||
A request to its own endpoints writes one line, without `servewaf_line`. A
|
A request to its own endpoints writes one line, without `discriminator_line`. A
|
||||||
report writes no line but the `report` line of the request it names.
|
report writes no line but the `report` line of the request it names.
|
||||||
|
|
||||||
These questions write no line:
|
These questions write no line:
|
||||||
|
|
||||||
- a question refused with `400` (see "The question" under "`servewaf`" in
|
- a question refused with `400` (see "The question" under "`discriminator`" in
|
||||||
[`SPEC.md`](../SPEC.md));
|
[`SPEC.md`](../SPEC.md));
|
||||||
- a question whose body stopped arriving, which gets no answer.
|
- a question whose body stopped arriving, which gets no answer.
|
||||||
|
|
||||||
A `report` line:
|
A `report` line:
|
||||||
|
|
||||||
```
|
```
|
||||||
{"type":"request","servewaf_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/servewaf","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104}
|
{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Every line
|
### Every line
|
||||||
@@ -37,14 +37,14 @@ Every one of these lines has:
|
|||||||
- the standard web log fields;
|
- the standard web log fields;
|
||||||
- `request_id`, `peer_ip` and `client_group`;
|
- `request_id`, `peer_ip` and `client_group`;
|
||||||
- `asn`, `as_name` and `country`;
|
- `asn`, `as_name` and `country`;
|
||||||
- `action` and `duration_total`.
|
- `action` and `duration_total_ms`.
|
||||||
|
|
||||||
### Standard web log fields
|
### Standard web log fields
|
||||||
|
|
||||||
- `time`: when `servewaf` had read the question's body, RFC 3339 with
|
- `time`: when `discriminator` had read the question's body, RFC 3339 with
|
||||||
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
||||||
line.
|
line.
|
||||||
- `instance`: `SWWAF_INSTANCE_NAME`.
|
- `instance`: `SWW_INSTANCE_NAME`.
|
||||||
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
||||||
- `method`, `path` and `query`: the visitor's request.
|
- `method`, `path` and `query`: the visitor's request.
|
||||||
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
||||||
@@ -52,8 +52,9 @@ Every one of these lines has:
|
|||||||
- `host`: the site the visitor asked for.
|
- `host`: the site the visitor asked for.
|
||||||
- `protocol`: the question's own, `HTTP/1.1`.
|
- `protocol`: the question's own, `HTTP/1.1`.
|
||||||
- `status`:
|
- `status`:
|
||||||
- on the `decision` line, what `servewaf` answered: `200` for `allow`, the
|
- on the `decision` line, what `discriminator` answered: `200` for `allow`,
|
||||||
status of `deny`, or `0` for `close`, as the visitor is then sent nothing;
|
the status of `deny`, or `0` for `close`, as the visitor is then sent
|
||||||
|
nothing;
|
||||||
- on the `report` line, what the visitor got, `0` if nothing.
|
- on the `report` line, what the visitor got, `0` if nothing.
|
||||||
- `request_bytes`:
|
- `request_bytes`:
|
||||||
- on the `decision` line, how much of the question's body the Core Rule Set
|
- on the `decision` line, how much of the question's body the Core Rule Set
|
||||||
@@ -61,7 +62,7 @@ Every one of these lines has:
|
|||||||
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
|
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
|
||||||
with what a WebSocket carried from the visitor.
|
with what a WebSocket carried from the visitor.
|
||||||
- `response_bytes`:
|
- `response_bytes`:
|
||||||
- on the `decision` line, the body of `servewaf`'s answer;
|
- on the `decision` line, the body of `discriminator`'s answer;
|
||||||
- on the `report` line, the bytes of the answer's body, with what a
|
- on the `report` line, the bytes of the answer's body, with what a
|
||||||
WebSocket carried from the app.
|
WebSocket carried from the app.
|
||||||
- `referer` and `user_agent`.
|
- `referer` and `user_agent`.
|
||||||
@@ -78,66 +79,109 @@ Every one of these lines has:
|
|||||||
- lookups are off;
|
- lookups are off;
|
||||||
- the client is not looked up;
|
- the client is not looked up;
|
||||||
- the source cannot place the client;
|
- the source cannot place the client;
|
||||||
- GeoJS had not answered by the time `servewaf` answered.
|
- GeoJS had not answered by the time `discriminator` answered.
|
||||||
- `content_type`: the visitor's `Content-Type`.
|
- `content_type`: the visitor's `Content-Type`.
|
||||||
- `content_length`: the body length the visitor announced.
|
- `content_length`: the body length the visitor announced.
|
||||||
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
|
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
|
||||||
in lower case.
|
lower case.
|
||||||
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
||||||
Their values are never logged.
|
Their values are never logged.
|
||||||
|
|
||||||
### Response detail
|
### Response detail
|
||||||
|
|
||||||
- `response_content_type`: the type of `servewaf`'s own answer.
|
- `response_content_type`: the type of `discriminator`'s own answer.
|
||||||
- `aborted`: true when `rproxy` went away before the answer.
|
- `aborted`: true when `rproxy` went away before the answer.
|
||||||
|
|
||||||
### Decision
|
### Decision
|
||||||
|
|
||||||
- `action`: what was done:
|
- `action`: what was done:
|
||||||
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||||
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
|
- `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with
|
||||||
the action `deny`;
|
the action `deny`, or for a tag in `SWW_TAG_DENY`;
|
||||||
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
- `temporarily_banned`: refused under a ban that runs out, or for a `ban`
|
||||||
CrowdSec decision, each of which bans the client;
|
rule, a trap path or a CrowdSec decision that made such a ban;
|
||||||
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
- `permanently_banned`: refused under a ban that never runs out, including a
|
||||||
refused by that check;
|
ban this request made or made permanent;
|
||||||
- `too_large`: over a header limit;
|
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
|
||||||
- `admin`: answered at one of `servewaf`'s own endpoints, or `404` for a
|
`blocked_by_core_rule_set`: refused by that check;
|
||||||
path under `/_smallwebwaf/`.
|
- `request_header_too_large`: over a header limit;
|
||||||
|
- `own_endpoint`: answered at one of `discriminator`'s own endpoints, or
|
||||||
|
`404` for a path under `/_sww/`.
|
||||||
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
||||||
request it would have refused. `action` then names what was done, normally
|
request it would have refused. `action` then names what was done, normally
|
||||||
`forward`.
|
`forward`.
|
||||||
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
|
- `heuristic`: the heuristic that refused the request, in `observe` mode too. It
|
||||||
rate limits and the setting that lowered it, when one did. `bytes_percent` and
|
is left out when none did.
|
||||||
`bytes_percent_setting` do the same for the byte limits.
|
- `heuristic_figures`: an object of the figures that heuristic acted on. Each
|
||||||
|
heuristic and its figures:
|
||||||
|
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in;
|
||||||
|
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
|
||||||
|
- `countries`: `country`, and `setting`, the country list that refused it;
|
||||||
|
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries;
|
||||||
|
- `blocklists`: `lists`, the blocklist URLs that list the client;
|
||||||
|
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
|
||||||
|
decision ends;
|
||||||
|
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
|
||||||
|
masked;
|
||||||
|
- `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`;
|
||||||
|
- `rate_limits`: `window`, `count` and `limit`;
|
||||||
|
- `trap_paths`: `path`, the trap path asked for;
|
||||||
|
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
|
||||||
|
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
|
||||||
|
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`;
|
||||||
|
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
|
||||||
|
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
|
||||||
|
- `after_answer_heuristic`: the heuristic that banned the client after
|
||||||
|
`discriminator` answered the request, in `observe` mode too: `refusal_limit`
|
||||||
|
or `byte_limits`.
|
||||||
|
- `after_answer_heuristic_figures`: an object of the figures that heuristic
|
||||||
|
acted on, as listed above.
|
||||||
|
|
||||||
|
`byte_limits` bans when the report comes, so only the `report` line names
|
||||||
|
it.
|
||||||
|
|
||||||
|
- `rate_limit_percent` and `rate_limit_percent_setting`: the client's percentage
|
||||||
|
of the rate limits and the setting that lowered it, when one did.
|
||||||
|
`bytes_limit_percent` and `bytes_limit_percent_setting` do the same for the
|
||||||
|
byte limits.
|
||||||
- `counts`: the client's totals as rate limiting counts them, this request
|
- `counts`: the client's totals as rate limiting counts them, this request
|
||||||
included. It is left out when rate limiting did not count the request. It
|
included. It is left out when rate limiting did not count the request. It
|
||||||
holds:
|
holds:
|
||||||
- `minute`, `hour` and `day`: requests;
|
- `minute`, `hour` and `day`: requests;
|
||||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
|
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
||||||
|
`SWW_BYTES_COUNT` names, in that window.
|
||||||
|
|
||||||
A count can have a fraction (see "Counting method" in
|
A count can have a fraction (see "Counting method" in
|
||||||
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
|
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
||||||
its report comes. So on the `decision` line, the bytes are without this
|
response are added when its report comes. So on the `decision` line, the
|
||||||
request's.
|
three byte totals leave them out.
|
||||||
|
|
||||||
- `limit_hit`: the limit broken:
|
- `limit_hit`: the limit broken:
|
||||||
- `minute`, `hour` or `day` for a rate limit;
|
- `minute`, `hour` or `day` for a rate limit;
|
||||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||||
- `error_burst`.
|
- `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`).
|
||||||
|
|
||||||
A request whose bytes broke a limit was passed on whole, so its `action`
|
When the bytes counted for a request and its response break a limit, the
|
||||||
stays `forward`. Only its `report` line gives that limit.
|
request has been passed on whole, so its `action` stays `forward`. Only its
|
||||||
|
`report` line gives that limit.
|
||||||
|
|
||||||
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
||||||
refused.
|
refused.
|
||||||
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that
|
- `core_rule_set_score` and `core_rule_set_rule_ids`: the Core Rule Set's score
|
||||||
matched, for a request it inspected.
|
and the rules that matched, for a request it inspected.
|
||||||
- `reputation`: the sources the client is on, in this order:
|
- `reputation`: the sources the client is on, in this order:
|
||||||
- blocklist URLs;
|
- blocklist URLs;
|
||||||
- the CrowdSec decision list's URL;
|
- the CrowdSec decision list's URL;
|
||||||
- DNS blocklist zones;
|
- DNS blocklist zones;
|
||||||
- `abuseipdb`.
|
- `abuseipdb`.
|
||||||
|
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
||||||
|
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
||||||
|
- `routewatch_tags_missing`: why the request got no tags from routewatch,
|
||||||
|
present only while `SWW_ROUTEWATCH_URL` is set:
|
||||||
|
- `no_answer_yet`: routewatch has not answered about this client yet;
|
||||||
|
- `request_failed`: a query to routewatch failed within the last minute, and
|
||||||
|
the process log says why;
|
||||||
|
- `no_route`: routewatch knows no live route for the address.
|
||||||
- `offence`: `limit`, for a request that broke a limit.
|
- `offence`: `limit`, for a request that broke a limit.
|
||||||
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||||
refused under one: a time, or `permanent`.
|
refused under one: a time, or `permanent`.
|
||||||
@@ -146,33 +190,33 @@ Every one of these lines has:
|
|||||||
|
|
||||||
All in milliseconds:
|
All in milliseconds:
|
||||||
|
|
||||||
- `duration_total`:
|
- `duration_total_ms`:
|
||||||
- on the `decision` line, from `servewaf` having read the question's body to
|
- on the `decision` line, from `discriminator` having read the question's
|
||||||
the line;
|
body to the line;
|
||||||
- on the `report` line, from `rproxy` having read the request's headers to
|
- on the `report` line, from `rproxy` having read the request's headers to
|
||||||
the end of the answer.
|
the end of the answer.
|
||||||
- `duration_checks`: to the end of the checks;
|
- `duration_checks_ms`: to the end of the checks;
|
||||||
- `duration_waf`: the part of that the Core Rule Set took;
|
- `duration_core_rule_set_ms`: the part of that the Core Rule Set took;
|
||||||
- `duration_upstream_total`: on the `report` line of an allowed request, from
|
- `duration_upstream_total_ms`: on the `report` line of an allowed request, from
|
||||||
the hand-over to the app to the end of the answer.
|
the hand-over to the app to the end of the answer.
|
||||||
|
|
||||||
### Requests to its own endpoints
|
### Requests to its own endpoints
|
||||||
|
|
||||||
The line of a request to `servewaf`'s own endpoints has these fields, with these
|
The line of a request to `discriminator`'s own endpoints has these fields, with
|
||||||
meanings where they differ:
|
these meanings where they differ:
|
||||||
|
|
||||||
- `client_ip` and `peer_ip`: the request's TCP peer.
|
- `client_ip` and `peer_ip`: the request's TCP peer.
|
||||||
- `request_id`: a new random one of 26 letters and digits.
|
- `request_id`: a new random one of 26 letters and digits.
|
||||||
- `scheme`: `http`.
|
- `scheme`: `http`.
|
||||||
- `time`: when the request's headers had been read.
|
- `time`: when the request's headers had been read.
|
||||||
- `request_bytes`: the bytes read of a ban to add, and otherwise `0`.
|
- `request_bytes`: the bytes read of a ban to add, and otherwise `0`.
|
||||||
- `action`: `admin`.
|
- `action`: `own_endpoint`.
|
||||||
- `duration_total`: from the request's headers being read to the line.
|
- `duration_total_ms`: from the request's headers being read to the line.
|
||||||
|
|
||||||
## `rproxy`'s line
|
## `rproxy`'s line
|
||||||
|
|
||||||
`smallwebwaf rproxy` writes one short line for each request. `request_id` joins
|
`sww rproxy` writes one short line for each request. `request_id` joins it to
|
||||||
it to `servewaf`'s two. It has these fields and no others:
|
`discriminator`'s two. It has these fields and no others:
|
||||||
|
|
||||||
- `type`: `request`.
|
- `type`: `request`.
|
||||||
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
||||||
@@ -185,22 +229,22 @@ it to `servewaf`'s two. It has these fields and no others:
|
|||||||
a WebSocket carried from the visitor.
|
a WebSocket carried from the visitor.
|
||||||
- `response_bytes`: the bytes of the answer's body, with what a WebSocket
|
- `response_bytes`: the bytes of the answer's body, with what a WebSocket
|
||||||
carried from the app.
|
carried from the app.
|
||||||
- `duration_request`: milliseconds from the request's headers being read until
|
- `duration_request_ms`: milliseconds from the request's headers being read
|
||||||
the app had been sent the whole request, or the answer started if that was
|
until the app had been sent the whole request, or the answer started if that
|
||||||
sooner. It includes the wait for `servewaf`.
|
was sooner. It includes the wait for `discriminator`.
|
||||||
- `duration_response`: milliseconds from then until the end of the answer.
|
- `duration_response_ms`: milliseconds from then until the end of the answer.
|
||||||
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
||||||
is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left
|
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
|
||||||
out when:
|
left out when:
|
||||||
- `servewaf` was not asked, as for the health check;
|
- `discriminator` was not asked, as for the health check;
|
||||||
- the visitor went away while `servewaf` was being asked;
|
- the visitor went away while `discriminator` was being asked;
|
||||||
- `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`;
|
- `discriminator`'s answer had no valid `X-Sww-Disposition`;
|
||||||
- `rproxy` could not pass on `servewaf`'s answer, and answered `502`.
|
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
|
||||||
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
||||||
`disposition`, and otherwise left out.
|
`disposition`, and otherwise left out.
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
```
|
```
|
||||||
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request":1.204,"duration_response":3.104,"disposition":"allow"}
|
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request_ms":1.204,"duration_response_ms":3.104,"disposition":"allow"}
|
||||||
```
|
```
|
||||||
+9
-8
@@ -32,7 +32,7 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
|||||||
## The default file
|
## The default file
|
||||||
|
|
||||||
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
||||||
`/etc/smallwebwaf/rules.d/00-default.rules`:
|
`/etc/sww/rules.d/00-default.rules`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
||||||
@@ -54,19 +54,20 @@ empty-agent user_agent log ^$
|
|||||||
|
|
||||||
## A file of your own
|
## A file of your own
|
||||||
|
|
||||||
`servewaf` reads the rule files in its own container. Rules of your own go in a
|
`discriminator` reads the rule files in its own container. Rules of your own go
|
||||||
file named to sort after the default, such as `50-gitea.rules`:
|
in a file named to sort after the default, such as `50-site.rules`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# 50-gitea.rules: WordPress probes, which gitea never serves
|
# 50-site.rules: WordPress probes, which this site never serves
|
||||||
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
||||||
```
|
```
|
||||||
|
|
||||||
A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from,
|
A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
|
||||||
adds it:
|
from, adds it:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
|
COPY 50-site.rules /etc/sww/rules.d/50-site.rules
|
||||||
```
|
```
|
||||||
|
|
||||||
Or mount it there. A `path` rule applies on every site behind that `servewaf`.
|
Or mount it there. A `path` rule applies on every site behind that
|
||||||
|
`discriminator`.
|
||||||
+251
-192
@@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
|
|||||||
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
||||||
that take `off`, the `_FILE` form, and what each group does and why.
|
that take `off`, the `_FILE` form, and what each group does and why.
|
||||||
|
|
||||||
The settings that start with `SWWAF_` are `servewaf`'s. Those that start with
|
The settings that start with `SWW_` are `discriminator`'s. Those that start with
|
||||||
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SMALLWEBWAF_COMMAND`
|
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
|
||||||
chooses between the two (see "The image's command" below).
|
between the two (see "The image's command" below).
|
||||||
|
|
||||||
## Forms of values
|
## Forms of values
|
||||||
|
|
||||||
@@ -28,278 +28,336 @@ chooses between the two (see "The image's command" below).
|
|||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `servewaf` listens: `:` and a
|
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
|
||||||
port, such as `:9000`. Leave the host out (see "Ports" in
|
port, such as `:9000`. Leave the host out (see "Ports" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance`
|
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
|
||||||
name in each line `servewaf` logs, and in each alert and metric, such as
|
in each line `discriminator` logs, and in each alert and metric, such as
|
||||||
`fsn1/servewaf`. Valid UTF-8. No control character while
|
`fsn1/discriminator`. Valid UTF-8. No control character while
|
||||||
`SWWAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
||||||
space while `SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is
|
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
|
||||||
not.
|
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
|
||||||
- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`.
|
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
|
||||||
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
|
group, from 32 to 128.
|
||||||
client's group, from 32 to 128.
|
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||||
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
|
||||||
number above zero.
|
number above zero.
|
||||||
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `servewaf` keeps,
|
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
|
||||||
a whole number above zero.
|
keeps, a whole number above zero.
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||||
`/_smallwebwaf/clients/<ip>`. At least 32 characters.
|
`/_sww/clients/<ip>`. At least 32 characters.
|
||||||
- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_smallwebwaf/metrics`.
|
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
|
||||||
At least 32 characters.
|
32 characters.
|
||||||
|
|
||||||
## State files
|
## State files
|
||||||
|
|
||||||
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
|
||||||
files, an absolute path that exists and can be written.
|
absolute path that exists and can be written.
|
||||||
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||||
written after a ban is made, lifted or made permanent. A duration.
|
written after a ban is made, lifted or made permanent. A duration.
|
||||||
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||||
written. A duration.
|
written. A duration.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `servewaf`'s own
|
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
|
||||||
messages that are written: `debug`, `info`, `warn` or `error`.
|
messages that are written: `debug`, `info`, `warn` or `error`.
|
||||||
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
- `SWW_LOG_REQUEST_HEADERS` (default
|
||||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||||
request headers recorded in the log line, in either case. `Host` and
|
request headers recorded in the log line, in either case. `Host` and
|
||||||
`Transfer-Encoding` are refused; the host is the field `host`.
|
`Transfer-Encoding` are refused; the host is the field `host`.
|
||||||
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||||
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
||||||
each with a host and a port and nothing more.
|
each with a host and a port and nothing more.
|
||||||
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
|
||||||
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
a `syslog+tls` server's certificate must chain to, instead of the host's own.
|
||||||
own. A file that cannot be read, or holds no certificate, is refused.
|
A file that cannot be read, or holds no certificate, is refused.
|
||||||
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
|
||||||
wait to be sent, a whole number above zero.
|
to be sent, a whole number above zero.
|
||||||
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||||
- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name in
|
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
|
||||||
each syslog record, 1 to 48 printable ASCII characters without a space.
|
syslog record, 1 to 48 printable ASCII characters without a space.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
|
||||||
countries get series of their own. A whole number above zero.
|
get series of their own. A whole number above zero.
|
||||||
|
|
||||||
## Static lists
|
## Static lists
|
||||||
|
|
||||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
||||||
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)).
|
(see step 4 in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||||
exempt from rate limiting and the byte limits.
|
exempt from rate limiting and the byte limits.
|
||||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
|
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
||||||
step 3 in [`SPEC.md`](../SPEC.md)).
|
4 in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Request rate limits, per client
|
## Request rate limits, per client
|
||||||
|
|
||||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
The request past one of these limits is refused, and the client is banned.
|
||||||
|
|
||||||
|
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||||
client may make in a minute. A count or `off`. The default leaves room for a
|
client may make in a minute. A count or `off`. The default leaves room for a
|
||||||
person opening two gitea pages at once with an empty cache, up to 60 files
|
person opening two pages at once with an empty cache, up to 60 files each.
|
||||||
each.
|
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||||
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
|
||||||
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
||||||
person reading code in gitea for an hour, a page every 20 seconds, makes
|
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
||||||
about 900. A scraper making one request every 2 seconds makes 1,800 an hour,
|
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
||||||
more than the limit.
|
|
||||||
- `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
|
|
||||||
make in a day. A count or `off`. The default is four hours at the hour limit.
|
|
||||||
A scraper making one request every 10 seconds makes 8,640 a day, more than the
|
|
||||||
limit.
|
limit.
|
||||||
- An Actions runner asks gitea for work all day and can pass the limits. Put the
|
- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make
|
||||||
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
in a day. A count or `off`. The default is four hours at the hour limit. A
|
||||||
|
scraper making one request every 10 seconds makes 8,640 a day, more than the
|
||||||
|
limit.
|
||||||
|
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
||||||
|
the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
||||||
network where many people share one address, such as an office.
|
network where many people share one address, such as an office.
|
||||||
- Counting cannot catch a client that stays under all three limits (see "Request
|
- Counting cannot catch a client that stays under all three limits (see "Request
|
||||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
|
||||||
rate limiting and the byte limits, such as `/assets/`.
|
limiting and the byte limits, such as `/assets/`.
|
||||||
|
|
||||||
## Byte limits, per client
|
## Byte limits, per client
|
||||||
|
|
||||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
These count body bytes, never headers. What the visitor sent over a WebSocket
|
||||||
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most
|
counts as request bytes, and what the app sent as response bytes. The bytes
|
||||||
bytes counted for a client in each window. A size or `off`.
|
counted for a request and its response are added to the client's total once the
|
||||||
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
response has ended. The request that takes a client's total past a limit is not
|
||||||
`response`, `request` or `both`.
|
cut off; the client is banned.
|
||||||
|
|
||||||
|
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
||||||
|
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
||||||
|
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says
|
||||||
|
which bytes count: response bodies, request bodies or both. The request that
|
||||||
|
passes the limit is not cut off, and the client is banned when its response
|
||||||
|
ends.
|
||||||
|
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly
|
||||||
|
thresholds count: `response`, the app's response bodies; `request`, the
|
||||||
|
visitors' request bodies; or `both`. What the visitor sent over a WebSocket
|
||||||
|
counts as request bytes, and what the app sent as response bytes.
|
||||||
|
|
||||||
## Lookup of AS number and country
|
## Lookup of AS number and country
|
||||||
|
|
||||||
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||||
the start while a setting that needs a lookup is set:
|
the start while a setting that needs a lookup is set:
|
||||||
- the two country lists;
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
||||||
- `SWWAF_ADD_LOOKUP_HEADERS`;
|
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||||
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`;
|
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an
|
||||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
`asn=` tag while `SWW_ROUTEWATCH_URL` is unset;
|
||||||
|
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
||||||
|
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||||
- an anomaly threshold per AS number.
|
- an anomaly threshold per AS number.
|
||||||
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||||
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
|
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
|
||||||
- `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
||||||
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
||||||
duration; never `off`.
|
duration; never `off`.
|
||||||
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||||
the client's AS number in `X-Client-ASN` and its country in
|
the client's AS number in `X-Client-ASN` and its country in
|
||||||
`X-Client-Country`.
|
`X-Client-Country`.
|
||||||
|
|
||||||
## Country lists
|
## Country list
|
||||||
|
|
||||||
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||||
such as `cn,ru,kp`.
|
countries whose clients get through, such as `us,de`. A country whose
|
||||||
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
`country=` tag is in `SWW_TAG_DENY` stops the start.
|
||||||
countries whose clients get through, such as `us,de`. A country on both lists
|
|
||||||
stops the start.
|
## Request tags
|
||||||
|
|
||||||
|
Every request gets tags from what `discriminator` knows of its client. See
|
||||||
|
"Request tags" in [`SPEC.md`](../SPEC.md).
|
||||||
|
|
||||||
|
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
|
||||||
|
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
|
||||||
|
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
|
||||||
|
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
|
||||||
|
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
|
||||||
|
or the CrowdSec decision list.
|
||||||
|
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
|
||||||
|
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
|
||||||
|
`country=` tag without a country, is refused.
|
||||||
|
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
|
||||||
|
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
|
||||||
|
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
|
||||||
|
client's tags applies. A tag listed twice is refused. So is
|
||||||
|
`exploitsignature`: a request gets that tag after the rate limits have counted
|
||||||
|
it.
|
||||||
|
|
||||||
|
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
|
||||||
|
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
|
||||||
|
service that gives an address's announced netblock and origin AS.
|
||||||
|
`discriminator` asks it about each client, in the background, and adds the
|
||||||
|
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
|
||||||
|
unset, nothing asks routewatch and no request gets a `prefix=` tag.
|
||||||
|
|
||||||
|
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
|
||||||
|
that no request can get stops the start. A request can get only these:
|
||||||
|
|
||||||
|
- a name in `SWW_TAG_LISTS`;
|
||||||
|
- an `asn=` or `country=` tag;
|
||||||
|
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches
|
||||||
|
only that exact announced netblock, such as `prefix=192.0.2.0/24`;
|
||||||
|
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
|
||||||
|
masked as the `reputation` field shows it;
|
||||||
|
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
|
||||||
|
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is
|
||||||
|
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is
|
||||||
|
`off`.
|
||||||
|
|
||||||
|
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
||||||
|
the request first.
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
||||||
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
percentage of the byte limits alone, for the AS numbers or countries listed,
|
||||||
listed twice is refused.
|
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
||||||
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
|
||||||
`cn:25,ru:50`.
|
country listed twice is refused.
|
||||||
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty):
|
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||||
the same, for the byte limits alone.
|
|
||||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
|
||||||
lookup cannot place.
|
lookup cannot place.
|
||||||
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||||
text file with one AS number and percentage on each line, such as
|
text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused.
|
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
|
||||||
|
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
|
||||||
rule files.
|
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||||
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||||
- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
|
||||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
||||||
- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||||
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||||
from 1 to 4.
|
from 1 to 4.
|
||||||
- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||||
match. A whole number above zero, or `off`.
|
match. A whole number above zero, or `off`.
|
||||||
- `SWWAF_WAF_DISABLED_RULES` (default
|
- `SWW_WAF_DISABLED_RULES` (default
|
||||||
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
||||||
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
||||||
refused: those are the rules that set the Core Rule Set up, and
|
refused: those are the rules that set the Core Rule Set up, and
|
||||||
`smallwebwaf`'s own.
|
`smallwebwaf`'s own.
|
||||||
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||||
not inspect.
|
not inspect.
|
||||||
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
|
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
|
||||||
the Core Rule Set reads, a size of at most `64K`. A question carries no more
|
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
|
||||||
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one
|
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
|
||||||
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
|
||||||
exclusions, written as Coraza directives. It is loaded before the Core Rule
|
exclusions, written as Coraza directives. It is loaded before the Core Rule
|
||||||
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
|
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
|
||||||
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
|
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
|
||||||
read or does not parse stops the start, whatever `SWWAF_WAF_MODE` is. So does
|
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
|
||||||
a file that names a number from 900000 to 900999 outside a comment, alone or
|
file that names a number from 900000 to 900999 outside a comment, alone or in
|
||||||
in a range. It has no `_FILE` form.
|
a range. So does a directive that removes or changes rules as the file loads,
|
||||||
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
|
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||||
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
yet. It has no `_FILE` form.
|
||||||
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
||||||
|
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||||
|
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||||
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||||
duration.
|
duration.
|
||||||
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||||
within which breaking a limit again lengthens the next ban. A duration.
|
within which breaking a limit again lengthens the next ban. A duration.
|
||||||
- `SWWAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
|
||||||
one that would be longer is permanent. A duration.
|
that would be longer is permanent. A duration.
|
||||||
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||||
attack. A duration.
|
attack. A duration.
|
||||||
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||||
covers: the prefix length of its netblock, from 0 to 32.
|
covers: the prefix length of its netblock, from 0 to 32.
|
||||||
|
|
||||||
## Reputation
|
## Reputation
|
||||||
|
|
||||||
- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||||
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
||||||
without a user or a fragment. A URL listed twice is refused.
|
without a user or a fragment. A URL listed twice is refused.
|
||||||
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
|
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
|
||||||
last try before it is fetched again. A duration of `1h` or more.
|
try before it is fetched again. A duration of `1h` or more.
|
||||||
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||||
- `SWWAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
||||||
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
||||||
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
||||||
is refused, whatever its case or key.
|
is refused, whatever its case or key.
|
||||||
- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
||||||
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
||||||
port, 53 when none is given. Unset, the host's resolver is used.
|
port, 53 when none is given. Unset, the host's resolver is used.
|
||||||
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||||
control character in it is refused.
|
control character in it is refused.
|
||||||
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||||
from 0 to 100, that is a hit.
|
from 0 to 100, that is a hit.
|
||||||
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day,
|
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
|
||||||
a whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
||||||
- `SWWAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
||||||
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
||||||
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
||||||
without the key, it is refused. So is a decision list URL that is also in
|
without the key, it is refused. So is a decision list URL that is also in
|
||||||
`SWWAF_BLOCKLIST_URLS`, or that is `SWWAF_ASN_LIMIT_PERCENT_URL`.
|
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
|
||||||
- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||||
URL, it is refused.
|
URL, it is refused.
|
||||||
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||||
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||||
AbuseIPDB's score is used. A duration.
|
AbuseIPDB's score is used. A duration.
|
||||||
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
|
||||||
check with AbuseIPDB may take. A duration.
|
with AbuseIPDB may take. A duration.
|
||||||
|
|
||||||
## Alerting
|
## Alerting
|
||||||
|
|
||||||
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
|
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
|
||||||
alert as a JSON object. An `http` or `https` URL without a user or a fragment.
|
as a JSON object. An `http` or `https` URL without a user or a fragment.
|
||||||
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
|
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
|
||||||
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
|
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
|
||||||
value. A value cannot hold a comma.
|
value cannot hold a comma.
|
||||||
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
|
||||||
that receives each alert as a message, checked as the webhook URL is.
|
receives each alert as a message, checked as the webhook URL is.
|
||||||
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
|
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
|
||||||
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
|
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
|
||||||
as the webhook URL is.
|
webhook URL is.
|
||||||
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||||
character in it is refused.
|
character in it is refused.
|
||||||
- `SWWAF_ALERT_EVENTS` (default
|
- `SWW_ALERT_EVENTS` (default
|
||||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
|
||||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||||
but `summary`. Any other name is refused.
|
but `summary`. Any other name is refused.
|
||||||
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||||
repeats are held back. A duration or `off`.
|
repeats are held back. A duration or `off`.
|
||||||
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||||
whole number above zero, or `off`.
|
whole number above zero, or `off`.
|
||||||
|
|
||||||
## Anomaly thresholds
|
## Anomaly thresholds
|
||||||
|
|
||||||
Each scope has four thresholds: the most requests and the most bytes counted in
|
Each scope has four thresholds: the most requests and the most body bytes
|
||||||
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names.
|
||||||
default. The four thresholds per client are:
|
Requests are counts, bytes are sizes. Each is `off` by default. The four
|
||||||
|
thresholds per client are:
|
||||||
|
|
||||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||||
|
|
||||||
The other scopes' names have another prefix before the same four endings, such
|
The other scopes' names have another prefix before the same four endings, such
|
||||||
as `REQUESTS_PER_MINUTE`:
|
as `REQUESTS_PER_MINUTE`:
|
||||||
|
|
||||||
- `SWWAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||||
netblock's length:
|
netblock's length:
|
||||||
- `SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||||
client;
|
client;
|
||||||
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||||
client.
|
client.
|
||||||
- `SWWAF_ANOMALY_ASN_...`: per AS number.
|
- `SWW_ANOMALY_ASN_...`: per AS number.
|
||||||
- `SWWAF_ANOMALY_TOTAL_...`: the whole service.
|
- `SWW_ANOMALY_TOTAL_...`: the whole service.
|
||||||
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole.
|
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
|
||||||
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
|
||||||
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||||
name, or with a bad netblock, is refused. So is a name used twice.
|
name, or with a bad netblock, is refused. So is a name used twice.
|
||||||
|
|
||||||
## Settings given as files
|
## Settings given as files
|
||||||
@@ -318,9 +376,9 @@ Then mount its directory into the container read-only, and name the file:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run \
|
docker run \
|
||||||
-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \
|
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
||||||
-e SMALLWEBWAF_COMMAND=servewaf \
|
-e SWW_COMMAND=discriminator \
|
||||||
-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \
|
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -333,41 +391,42 @@ A few limits are fixed rather than settings:
|
|||||||
- at most 1,000 DNS blocklist queries are under way at once;
|
- at most 1,000 DNS blocklist queries are under way at once;
|
||||||
- at most 20,000 anomaly counters are kept;
|
- at most 20,000 anomaly counters are kept;
|
||||||
- each alert destination's queue holds at most 1,000 alerts;
|
- each alert destination's queue holds at most 1,000 alerts;
|
||||||
- `servewaf` holds at most 10,000 answered requests awaiting their reports;
|
- `discriminator` holds at most 10,000 answered requests awaiting their reports;
|
||||||
- `servewaf` refuses a request with more than 50 cookies or more than 100 header
|
- `discriminator` refuses a request with more than 50 cookies or more than 100
|
||||||
fields (see "Answering in time" under "`servewaf`" in
|
header fields (see "Answering in time" under "`discriminator`" in
|
||||||
[`SPEC.md`](../SPEC.md));
|
[`SPEC.md`](../SPEC.md));
|
||||||
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a
|
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a
|
||||||
kept-open connection idle for 120 seconds;
|
kept-open connection idle for 120 seconds;
|
||||||
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers
|
- `rproxy` reads at most 1 MiB of the body of `discriminator`'s answer, and
|
||||||
`502` to a longer one.
|
answers `502` to a longer one.
|
||||||
|
|
||||||
## `servewaf`'s answer and `rproxy`'s wait
|
## `discriminator`'s answer and `rproxy`'s wait
|
||||||
|
|
||||||
`SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
|
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
|
||||||
Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question
|
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
|
||||||
waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are
|
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
|
||||||
asked in the background (see "Answering in time" under "`servewaf`" in
|
in the background (see "Answering in time" under "`discriminator`" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## `rproxy`
|
## `rproxy`
|
||||||
|
|
||||||
`smallwebwaf rproxy` reads the settings below and no others. They are read once
|
`sww rproxy` reads the settings below and no others. They are read once at
|
||||||
at start, and an invalid value stops the start. None can be given as a file,
|
start, and an invalid value stops the start. None can be given as a file, since
|
||||||
since `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says
|
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
|
||||||
what they do.
|
they do.
|
||||||
|
|
||||||
A time here is a whole number of milliseconds above zero, such as `2000`.
|
A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||||
|
|
||||||
- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or
|
- `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or
|
||||||
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||||
speaks HTTP/1.1 to it.
|
speaks HTTP/1.1 to it.
|
||||||
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s
|
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
|
||||||
answer, to a question and to a report. A time; never `off`.
|
`discriminator`'s answer, to a question and to a report. A time; never `off`.
|
||||||
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
|
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
|
||||||
`servewaf` cannot be connected to, does not answer at all, or does not answer
|
`discriminator` cannot be connected to, does not answer at all, or does not
|
||||||
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
|
answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
|
||||||
`503`. It does not apply to an answer `servewaf` gives, such as an error.
|
answers `503`. It does not apply to an answer `discriminator` gives, such as
|
||||||
|
an error.
|
||||||
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
||||||
`off`.
|
`off`.
|
||||||
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
||||||
@@ -391,22 +450,22 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
|||||||
|
|
||||||
## The image's command
|
## The image's command
|
||||||
|
|
||||||
`SMALLWEBWAF_COMMAND` (default `rproxy`) chooses what the image runs and what
|
`SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health
|
||||||
its health check checks: `rproxy` or `servewaf` (see "Deployment" in
|
check checks: `rproxy` or `discriminator` (see "Deployment" in
|
||||||
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
|
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
|
||||||
|
|
||||||
## Where earlier settings went
|
## Where earlier settings went
|
||||||
|
|
||||||
Nothing reads these names any more:
|
Nothing reads these names any more:
|
||||||
|
|
||||||
- `SWWAF_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
||||||
- `SWWAF_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
||||||
- `SWWAF_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
||||||
- `SWWAF_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
||||||
- `SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` are
|
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
|
||||||
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
||||||
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` and `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` are
|
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
|
||||||
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
||||||
- `SWWAF_CLIENT_IDLE_TIMEOUT` and `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` have
|
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
|
||||||
no setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
||||||
takes a request line and headers of at most 32 KiB.
|
takes a request line and headers of at most 32 KiB.
|
||||||
+11
-10
@@ -1,13 +1,13 @@
|
|||||||
# State files
|
# State files
|
||||||
|
|
||||||
The five JSON files in `SWWAF_STATE_DIR`, and what each holds. "Persistent
|
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
|
||||||
state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited.
|
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
|
||||||
Each has a top-level `version` of 1.
|
a top-level `version` of 1.
|
||||||
|
|
||||||
## `bans.json`
|
## `bans.json`
|
||||||
|
|
||||||
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of
|
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
||||||
those `servewaf` made. Per entry:
|
`discriminator` made. Per entry:
|
||||||
|
|
||||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||||
@@ -61,10 +61,11 @@ since it was first seen:
|
|||||||
- the first and last time seen;
|
- the first and last time seen;
|
||||||
- the AS number, AS name and country last looked up, and when;
|
- the AS number, AS name and country last looked up, and when;
|
||||||
- total requests, and how many were forwarded and how many refused;
|
- total requests, and how many were forwarded and how many refused;
|
||||||
- bytes in each direction;
|
- body bytes of its requests, and of the responses it was sent;
|
||||||
- responses by status class;
|
- responses by status class;
|
||||||
- offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file
|
- offences by kind: `limit`, `attack`, `blocked_by_block_rule` and
|
||||||
may also hold `token_refused`, which is kept, and which nothing adds to.
|
`blocked_by_core_rule_set`. A file may also hold `token_refused`, which is
|
||||||
|
kept, and which nothing adds to.
|
||||||
|
|
||||||
## `lookups.json`
|
## `lookups.json`
|
||||||
|
|
||||||
@@ -93,10 +94,10 @@ GeoJS answers, one per line:
|
|||||||
## Sizes
|
## Sizes
|
||||||
|
|
||||||
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
||||||
`SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||||
disk writes a day.
|
disk writes a day.
|
||||||
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
||||||
the default `SWWAF_MAX_BANS`. Written every 15 minutes, a full file comes to
|
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||||
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
||||||
more.
|
more.
|
||||||
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
||||||
|
|||||||
+48
-46
@@ -1,12 +1,12 @@
|
|||||||
// Package alerts sends alerts on bans, on traffic over an anomaly
|
// Package alerts sends alerts on bans, on traffic over an anomaly
|
||||||
// threshold, on a source that fails and on a file with an error to each
|
// threshold, on a source that fails and on a file with an error to each
|
||||||
// destination set: to the webhook
|
// destination set: to the webhook
|
||||||
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||||
// object" in docs/alerts.md describes, to the Slack incoming
|
// object" in docs/alerts.md describes, to the Slack incoming
|
||||||
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||||
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within
|
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
|
||||||
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past
|
// SWW_ALERT_COOLDOWN is held back, and so is an alert past
|
||||||
// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||||
// bounded queue of each destination's own, so that a destination that is
|
// bounded queue of each destination's own, so that a destination that is
|
||||||
// slow or unreachable holds up neither the others nor any request. The
|
// slow or unreachable holds up neither the others nor any request. The
|
||||||
// state is written to alerts.json and read from it by the state package.
|
// state is written to alerts.json and read from it by the state package.
|
||||||
@@ -34,45 +34,45 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The events an alert is for, as SWWAF_ALERT_EVENTS names them.
|
// The events an alert is for, as SWW_ALERT_EVENTS names them.
|
||||||
const (
|
const (
|
||||||
// EventBan is a ban smallwebwaf made.
|
// EventBan is a ban sww made.
|
||||||
EventBan = "ban"
|
EventBan = "ban"
|
||||||
// EventPermanentBan is a permanent ban smallwebwaf made, or a ban it
|
// EventPermanentBan is a permanent ban sww made, or a ban it
|
||||||
// made permanent.
|
// made permanent.
|
||||||
EventPermanentBan = "permanent_ban"
|
EventPermanentBan = "permanent_ban"
|
||||||
// EventAnomaly is a count of requests or bytes over an anomaly
|
// EventAnomaly is a count of requests or bytes over an anomaly
|
||||||
// threshold.
|
// threshold.
|
||||||
EventAnomaly = "anomaly"
|
EventAnomaly = "anomaly"
|
||||||
// EventWAFBlock is a request the Core Rule Set scored at or over
|
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||||
// detect mode.
|
// detect mode.
|
||||||
EventWAFBlock = "waf_block"
|
EventCoreRuleSetMatch = "core_rule_set_match"
|
||||||
// EventReputationHit is a request whose client a blocklist, the
|
// EventReputationHit is a request whose client a blocklist, the
|
||||||
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
||||||
// is a hit.
|
// is a hit.
|
||||||
EventReputationHit = "reputation_hit"
|
EventReputationHit = "reputation_hit"
|
||||||
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
|
// EventSourceFailure is GeoJS failing or refusing sww, a fetch
|
||||||
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
||||||
// failing or refused, or the day's AbuseIPDB checks used up.
|
// failing or refused, or the day's AbuseIPDB checks used up.
|
||||||
EventSourceFailure = "source_failure"
|
EventSourceFailure = "source_failure"
|
||||||
// EventFileError is a rule file or state file edited while smallwebwaf
|
// EventFileError is a rule file or state file edited while sww
|
||||||
// runs that does not parse, a replacement of the lookup database that
|
// runs that does not parse, a replacement of the lookup database that
|
||||||
// cannot be read, or a state file that cannot be written.
|
// cannot be read, or a state file that cannot be written.
|
||||||
EventFileError = "file_error"
|
EventFileError = "file_error"
|
||||||
// EventSummary is the summary sent as an hour ends: of the alerts held
|
// EventSummary is the summary sent as an hour ends: of the alerts held
|
||||||
// back in it past SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held
|
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||||
// back by the cooldowns dropped as it ends, which no alert let through
|
// back by the cooldowns dropped as it ends, which no alert let through
|
||||||
// has given. It is sent with SWWAF_ALERT_MAX_PER_HOUR off too, for
|
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
|
||||||
// those repeats. SWWAF_ALERT_EVENTS does not name it.
|
// those repeats. SWW_ALERT_EVENTS does not name it.
|
||||||
EventSummary = "summary"
|
EventSummary = "summary"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Events returns every event SWWAF_ALERT_EVENTS can name, which is its
|
// Events returns every event SWW_ALERT_EVENTS can name, which is its
|
||||||
// default.
|
// default.
|
||||||
func Events() []string {
|
func Events() []string {
|
||||||
return []string{
|
return []string{
|
||||||
EventBan, EventPermanentBan, EventWAFBlock, EventAnomaly,
|
EventBan, EventPermanentBan, EventCoreRuleSetMatch, EventAnomaly,
|
||||||
EventReputationHit, EventSourceFailure, EventFileError,
|
EventReputationHit, EventSourceFailure, EventFileError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,12 +80,12 @@ func Events() []string {
|
|||||||
// The destinations alerts are sent to, as the metrics and alerts.json
|
// The destinations alerts are sent to, as the metrics and alerts.json
|
||||||
// name them.
|
// name them.
|
||||||
const (
|
const (
|
||||||
// DestinationWebhook is the webhook SWWAF_ALERT_WEBHOOK_URL names.
|
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
|
||||||
DestinationWebhook = "webhook"
|
DestinationWebhook = "webhook"
|
||||||
// DestinationSlack is the Slack incoming webhook
|
// DestinationSlack is the Slack incoming webhook
|
||||||
// SWWAF_ALERT_SLACK_WEBHOOK_URL names.
|
// SWW_ALERT_SLACK_WEBHOOK_URL names.
|
||||||
DestinationSlack = "slack"
|
DestinationSlack = "slack"
|
||||||
// DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names.
|
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
|
||||||
DestinationNtfy = "ntfy"
|
DestinationNtfy = "ntfy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,26 +121,26 @@ var (
|
|||||||
// NtfyURL set, no alert is sent.
|
// NtfyURL set, no alert is sent.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// WebhookURL is where each alert is posted as JSON
|
// WebhookURL is where each alert is posted as JSON
|
||||||
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||||
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS).
|
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
||||||
WebhookURL *url.URL
|
WebhookURL *url.URL
|
||||||
WebhookHeaders http.Header
|
WebhookHeaders http.Header
|
||||||
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
||||||
// message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||||
SlackURL *url.URL
|
SlackURL *url.URL
|
||||||
// NtfyURL is the ntfy topic each alert is published to
|
// NtfyURL is the ntfy topic each alert is published to
|
||||||
// (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||||
// empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN).
|
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
|
||||||
NtfyURL *url.URL
|
NtfyURL *url.URL
|
||||||
NtfyToken string
|
NtfyToken string
|
||||||
// Events are the events alerts are sent for (SWWAF_ALERT_EVENTS).
|
// Events are the events alerts are sent for (SWW_ALERT_EVENTS).
|
||||||
Events []string
|
Events []string
|
||||||
// Cooldown is how long a repeat of an alert is held back
|
// Cooldown is how long a repeat of an alert is held back
|
||||||
// (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||||
// sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit.
|
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||||
Cooldown time.Duration
|
Cooldown time.Duration
|
||||||
MaxPerHour int
|
MaxPerHour int
|
||||||
// Instance is SWWAF_INSTANCE_NAME, which every alert gives.
|
// Instance is SWW_INSTANCE_NAME, which every alert gives.
|
||||||
Instance string
|
Instance string
|
||||||
// Now tells the time of an alert, normally time.Now in UTC.
|
// Now tells the time of an alert, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -151,7 +151,7 @@ type Params struct {
|
|||||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||||
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
||||||
// Country are, for a ban, the client's as the ban's notes give them. Host is
|
// Country are, for a ban, the client's as the ban's notes give them. Host is
|
||||||
// the site of the request that raised the alert, while one servewaf serves
|
// the site of the request that raised the alert, while one discriminator serves
|
||||||
// many sites, and "" otherwise.
|
// many sites, and "" otherwise.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||||
@@ -220,7 +220,7 @@ type State struct {
|
|||||||
// requests to it failed, and how many alerts were dropped from its full
|
// requests to it failed, and how many alerts were dropped from its full
|
||||||
// queue or given up as it refused them.
|
// queue or given up as it refused them.
|
||||||
type Counts struct {
|
type Counts struct {
|
||||||
Sent, Failed, Dropped int64
|
Sent, SendFailures, Dropped int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue takes the alerts raised, holds back those it must, and sends the
|
// Queue takes the alerts raised, holds back those it must, and sends the
|
||||||
@@ -264,7 +264,7 @@ type destination struct {
|
|||||||
// waiting are the alerts waiting to be sent, oldest first.
|
// waiting are the alerts waiting to be sent, oldest first.
|
||||||
waiting []*Alert
|
waiting []*Alert
|
||||||
|
|
||||||
sent, failed, dropped atomic.Int64
|
sent, sendFailures, dropped atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// cooldownKey is what makes an alert a repeat of another: the same event
|
// cooldownKey is what makes an alert a repeat of another: the same event
|
||||||
@@ -301,17 +301,17 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if params.WebhookURL != nil {
|
if params.WebhookURL != nil {
|
||||||
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL",
|
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
|
||||||
params.WebhookURL, q.webhookMessage)
|
params.WebhookURL, q.webhookMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.SlackURL != nil {
|
if params.SlackURL != nil {
|
||||||
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL",
|
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
|
||||||
params.SlackURL, slackMessage)
|
params.SlackURL, slackMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.NtfyURL != nil {
|
if params.NtfyURL != nil {
|
||||||
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL",
|
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
|
||||||
params.NtfyURL, q.ntfyMessage)
|
params.NtfyURL, q.ntfyMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Raise sends alert, which names its event and what is particular to it,
|
// Raise sends alert, which names its event and what is particular to it,
|
||||||
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event
|
// unless no destination is set or SWW_ALERT_EVENTS leaves its event
|
||||||
// out. It gives alert the instance and the time. An alert that repeats
|
// out. It gives alert the instance and the time. An alert that repeats
|
||||||
// the last one let through less than Cooldown before is held back and
|
// the last one let through less than Cooldown before is held back and
|
||||||
// counted. The next one let through gives that count, unless an hour of
|
// counted. The next one let through gives that count, unless an hour of
|
||||||
@@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WouldSend reports whether Raise would let an alert for event on
|
// WouldSend reports whether Raise would let an alert for event on
|
||||||
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses
|
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
|
||||||
// event, no alert for event on netblock was let through less than
|
// event, no alert for event on netblock was let through less than
|
||||||
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
||||||
// in the hour under way. Unlike Raise, it counts nothing.
|
// in the hour under way. Unlike Raise, it counts nothing.
|
||||||
@@ -426,7 +426,9 @@ func (q *Queue) Counts(name string) Counts {
|
|||||||
for _, d := range q.destinations {
|
for _, d := range q.destinations {
|
||||||
if d.name == name {
|
if d.name == name {
|
||||||
return Counts{
|
return Counts{
|
||||||
Sent: d.sent.Load(), Failed: d.failed.Load(), Dropped: d.dropped.Load(),
|
Sent: d.sent.Load(),
|
||||||
|
SendFailures: d.sendFailures.Load(),
|
||||||
|
Dropped: d.dropped.Load(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -606,13 +608,13 @@ func (q *Queue) endHour(now time.Time) {
|
|||||||
|
|
||||||
if heldBack > 0 {
|
if heldBack > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
||||||
"past the %d an hour SWWAF_ALERT_MAX_PER_HOUR allows", heldBack,
|
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||||
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
||||||
}
|
}
|
||||||
|
|
||||||
if repeats > 0 {
|
if repeats > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
||||||
"SWWAF_ALERT_COOLDOWN that no later alert gives", repeats))
|
"SWW_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(reasons) > 0 {
|
if len(reasons) > 0 {
|
||||||
@@ -675,7 +677,7 @@ func (d *destination) run(ctx context.Context) {
|
|||||||
retryAt = time.Time{}
|
retryAt = time.Time{}
|
||||||
case errors.Is(err, errRefused):
|
case errors.Is(err, errRefused):
|
||||||
d.remove(alert)
|
d.remove(alert)
|
||||||
d.failed.Add(1)
|
d.sendFailures.Add(1)
|
||||||
d.dropped.Add(1)
|
d.dropped.Add(1)
|
||||||
|
|
||||||
retryDelay = 0
|
retryDelay = 0
|
||||||
@@ -683,8 +685,8 @@ func (d *destination) run(ctx context.Context) {
|
|||||||
|
|
||||||
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
||||||
"event", alert.Event, "error", err.Error())
|
"event", alert.Event, "error", err.Error())
|
||||||
case ctx.Err() == nil: // not cut off as smallwebwaf stops
|
case ctx.Err() == nil: // not cut off as sww stops
|
||||||
d.failed.Add(1)
|
d.sendFailures.Add(1)
|
||||||
|
|
||||||
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
||||||
maxRetryDelay)
|
maxRetryDelay)
|
||||||
@@ -871,7 +873,7 @@ func ntfyPriority(event string) string {
|
|||||||
return "high"
|
return "high"
|
||||||
case EventReputationHit:
|
case EventReputationHit:
|
||||||
return "low"
|
return "low"
|
||||||
default: // ban, waf_block and summary
|
default: // ban, core_rule_set_match and summary
|
||||||
return "default"
|
return "default"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -882,7 +884,7 @@ func ntfyTag(event string) string {
|
|||||||
switch event {
|
switch event {
|
||||||
case EventBan, EventPermanentBan:
|
case EventBan, EventPermanentBan:
|
||||||
return "no_entry"
|
return "no_entry"
|
||||||
case EventWAFBlock:
|
case EventCoreRuleSetMatch:
|
||||||
return "shield"
|
return "shield"
|
||||||
case EventAnomaly:
|
case EventAnomaly:
|
||||||
return "chart_with_upwards_trend"
|
return "chart_with_upwards_trend"
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ const (
|
|||||||
// webhookURL is where the alerts are posted, slackURL the Slack
|
// webhookURL is where the alerts are posted, slackURL the Slack
|
||||||
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
||||||
// token of the tests that set one.
|
// token of the tests that set one.
|
||||||
webhookURL = "https://alerts.example/smallwebwaf?team=ops"
|
webhookURL = "https://alerts.example/sww?team=ops"
|
||||||
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||||
ntfyURL = "https://ntfy.example/smallwebwaf-alerts"
|
ntfyURL = "https://ntfy.example/sww-alerts"
|
||||||
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
||||||
// instance is the instance name every alert gives.
|
// instance is the instance name every alert gives.
|
||||||
instance = "fsn1app1/gitea"
|
instance = "fsn1app1/blog"
|
||||||
// started is when each test starts, as an alert gives it, and
|
// started is when each test starts, as an alert gives it, and
|
||||||
// anHourOn an hour later.
|
// anHourOn an hour later.
|
||||||
started = "2000-01-01T00:00:00Z"
|
started = "2000-01-01T00:00:00Z"
|
||||||
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
|
|||||||
|
|
||||||
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
||||||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
||||||
t.Error("a ban alert would be sent, though SWWAF_ALERT_EVENTS leaves it out")
|
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
||||||
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
|
|||||||
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
||||||
|
|
||||||
for setting, params := range map[string]alerts.Params{
|
for setting, params := range map[string]alerts.Params{
|
||||||
"SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||||
"SWWAF_ALERT_NTFY_URL": onlyNtfy,
|
"SWW_ALERT_NTFY_URL": onlyNtfy,
|
||||||
} {
|
} {
|
||||||
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
||||||
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
||||||
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
|
|||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 2 an hour SWWAF_ALERT_MAX_PER_HOUR allows",
|
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
|
||||||
"detail": map[string]any{
|
"detail": map[string]any{
|
||||||
"hour": started,
|
"hour": started,
|
||||||
"count": float64(3),
|
"count": float64(3),
|
||||||
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
|
|||||||
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) {
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
|
||||||
t.Errorf("process log %q names no failure", log.String())
|
t.Errorf("process log %q names no failure", log.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
|
|||||||
wantCounts(t, q, 1, 3, 0, 1)
|
wantCounts(t, q, 1, 3, 0, 1)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"gave up an alert SWWAF_ALERT_WEBHOOK_URL refused"`) {
|
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
|
||||||
t.Errorf("process log %q names no alert given up", log.String())
|
t.Errorf("process log %q names no alert given up", log.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
}
|
}
|
||||||
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 1 an hour SWWAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||||
"by SWWAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||||
|
|
||||||
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
||||||
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
||||||
@@ -897,11 +897,11 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
|
|||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
|
|
||||||
wantDestinationCounts(t, q, alerts.DestinationSlack,
|
wantDestinationCounts(t, q, alerts.DestinationSlack,
|
||||||
alerts.Counts{Sent: 3, Failed: 1})
|
alerts.Counts{Sent: 3, SendFailures: 1})
|
||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWWAF_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "hooks.slack.example") ||
|
strings.Contains(logged, "hooks.slack.example") ||
|
||||||
strings.Contains(logged, "T0123") {
|
strings.Contains(logged, "T0123") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
@@ -934,7 +934,7 @@ func TestDropsAreCountedForEachDestination(t *testing.T) {
|
|||||||
for destination, want := range map[string]alerts.Counts{
|
for destination, want := range map[string]alerts.Counts{
|
||||||
alerts.DestinationWebhook: {Sent: all},
|
alerts.DestinationWebhook: {Sent: all},
|
||||||
alerts.DestinationSlack: {Dropped: 1},
|
alerts.DestinationSlack: {Dropped: 1},
|
||||||
alerts.DestinationNtfy: {Failed: all, Dropped: all},
|
alerts.DestinationNtfy: {SendFailures: all, Dropped: all},
|
||||||
} {
|
} {
|
||||||
wantDestinationCounts(t, q, destination, want)
|
wantDestinationCounts(t, q, destination, want)
|
||||||
}
|
}
|
||||||
@@ -965,8 +965,8 @@ func TestEachDestinationIsSentOnlyTheAlertsWaitingForIt(t *testing.T) {
|
|||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
|
|
||||||
wantBodies(t, standIns[alerts.DestinationSlack],
|
wantBodies(t, standIns[alerts.DestinationSlack],
|
||||||
`{"text":"*fsn1app1/gitea: file_error*\nsecond"}`,
|
`{"text":"*fsn1app1/blog: file_error*\nsecond"}`,
|
||||||
`{"text":"*fsn1app1/gitea: file_error*\nthird"}`)
|
`{"text":"*fsn1app1/blog: file_error*\nthird"}`)
|
||||||
wantBodies(t, standIns[alerts.DestinationNtfy], "fourth")
|
wantBodies(t, standIns[alerts.DestinationNtfy], "fourth")
|
||||||
|
|
||||||
// alerts.json then lists Slack and ntfy alone, with no alert waiting.
|
// alerts.json then lists Slack and ntfy alone, with no alert waiting.
|
||||||
@@ -1223,12 +1223,12 @@ func wantEvents(t *testing.T, webhook *standIn, want ...string) {
|
|||||||
// requests to it that it counts as failed, the alerts it counts as held
|
// requests to it that it counts as failed, the alerts it counts as held
|
||||||
// back, and those it counts as dropped for the webhook.
|
// back, and those it counts as dropped for the webhook.
|
||||||
func wantCounts(
|
func wantCounts(
|
||||||
t *testing.T, q *alerts.Queue, sent, failed, suppressed, dropped int64,
|
t *testing.T, q *alerts.Queue, sent, sendFailures, suppressed, dropped int64,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
wantDestinationCounts(t, q, alerts.DestinationWebhook,
|
wantDestinationCounts(t, q, alerts.DestinationWebhook,
|
||||||
alerts.Counts{Sent: sent, Failed: failed, Dropped: dropped})
|
alerts.Counts{Sent: sent, SendFailures: sendFailures, Dropped: dropped})
|
||||||
|
|
||||||
if q.Suppressed() != suppressed {
|
if q.Suppressed() != suppressed {
|
||||||
t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed)
|
t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed)
|
||||||
@@ -1254,7 +1254,7 @@ type eventMessage struct {
|
|||||||
title, priorityAndTag, text string
|
title, priorityAndTag, text string
|
||||||
}
|
}
|
||||||
|
|
||||||
// anAlertForEachEvent returns an alert for each event SWWAF_ALERT_EVENTS
|
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
|
||||||
// names, the first in observe mode, each with its message.
|
// names, the first in observe mode, each with its message.
|
||||||
func anAlertForEachEvent() []eventMessage {
|
func anAlertForEachEvent() []eventMessage {
|
||||||
return []eventMessage{
|
return []eventMessage{
|
||||||
@@ -1265,7 +1265,7 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Reason: "requests per hour over the limit of 10000",
|
Reason: "requests per hour over the limit of 10000",
|
||||||
Detail: map[string]any{"mode": "observe"},
|
Detail: map[string]any{"mode": "observe"},
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: ban", "default no_entry",
|
"fsn1app1/blog: ban", "default no_entry",
|
||||||
"requests per hour over the limit of 10000\nclient: 203.0.113.9\n" +
|
"requests per hour over the limit of 10000\nclient: 203.0.113.9\n" +
|
||||||
"netblock: 203.0.113.0/24\ncountry: DE\nmode: observe",
|
"netblock: 203.0.113.0/24\ncountry: DE\nmode: observe",
|
||||||
},
|
},
|
||||||
@@ -1275,18 +1275,18 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR",
|
Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR",
|
||||||
Reason: "matched the rule env-file",
|
Reason: "matched the rule env-file",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: permanent_ban", "high no_entry",
|
"fsn1app1/blog: permanent_ban", "high no_entry",
|
||||||
"matched the rule env-file\nclient: 198.51.100.7\n" +
|
"matched the rule env-file\nclient: 198.51.100.7\n" +
|
||||||
"netblock: 198.51.100.7/32\ncountry: FR",
|
"netblock: 198.51.100.7/32\ncountry: FR",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alerts.Alert{
|
alerts.Alert{
|
||||||
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
Event: alerts.EventCoreRuleSetMatch, Client: netip.MustParseAddr("192.0.2.1"),
|
||||||
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
||||||
Reason: "refused by the Core Rule Set",
|
Reason: "refused by the Core Rule Set",
|
||||||
Host: "app.example",
|
Host: "app.example",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: waf_block", "default shield",
|
"fsn1app1/blog: core_rule_set_match", "default shield",
|
||||||
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
|
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
|
||||||
"netblock: 192.0.2.1/32",
|
"netblock: 192.0.2.1/32",
|
||||||
},
|
},
|
||||||
@@ -1295,7 +1295,7 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
|
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
|
||||||
Reason: "requests per minute over the threshold of 5000",
|
Reason: "requests per minute over the threshold of 5000",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: anomaly", "high chart_with_upwards_trend",
|
"fsn1app1/blog: anomaly", "high chart_with_upwards_trend",
|
||||||
"requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24",
|
"requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1304,7 +1304,7 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Netblock: netip.MustParsePrefix("192.0.2.2/32"),
|
Netblock: netip.MustParsePrefix("192.0.2.2/32"),
|
||||||
Reason: "listed by a DNS blocklist",
|
Reason: "listed by a DNS blocklist",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: reputation_hit", "low label",
|
"fsn1app1/blog: reputation_hit", "low label",
|
||||||
"listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32",
|
"listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1312,7 +1312,7 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
|
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
|
||||||
Detail: map[string]any{"source": "geojs"},
|
Detail: map[string]any{"source": "geojs"},
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: source_failure", "high warning",
|
"fsn1app1/blog: source_failure", "high warning",
|
||||||
"asking GeoJS failed\nsource: geojs",
|
"asking GeoJS failed\nsource: geojs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1320,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: "a rule file has an error, and the rules stay as they were",
|
Reason: "a rule file has an error, and the rules stay as they were",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action",
|
"file": "/etc/sww/rules.d/50-app.rules", "error": "line 2: no action",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: file_error", "high warning",
|
"fsn1app1/blog: file_error", "high warning",
|
||||||
"a rule file has an error, and the rules stay as they were\n" +
|
"a rule file has an error, and the rules stay as they were\n" +
|
||||||
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action",
|
"file: /etc/sww/rules.d/50-app.rules\nerror: line 2: no action",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-16
@@ -28,22 +28,22 @@ const maxCounters = 20000
|
|||||||
// alerts name them.
|
// alerts name them.
|
||||||
const (
|
const (
|
||||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
||||||
// SWWAF_IPV6_GROUP_PREFIX.
|
// SWW_IPV6_GROUP_PREFIX.
|
||||||
ScopeClient = "client"
|
ScopeClient = "client"
|
||||||
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX
|
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
|
||||||
// or SWWAF_ANOMALY_NET_V6_PREFIX long.
|
// or SWW_ANOMALY_NET_V6_PREFIX long.
|
||||||
ScopeNet = "net"
|
ScopeNet = "net"
|
||||||
// ScopeASN is an AS number.
|
// ScopeASN is an AS number.
|
||||||
ScopeASN = "asn"
|
ScopeASN = "asn"
|
||||||
// ScopeTotal is the whole service.
|
// ScopeTotal is the whole service.
|
||||||
ScopeTotal = "total"
|
ScopeTotal = "total"
|
||||||
// ScopeWatch is a named netblock of SWWAF_WATCH_NETS.
|
// ScopeWatchedNet is a named netblock of SWW_WATCH_NETS.
|
||||||
ScopeWatch = "watch"
|
ScopeWatchedNet = "watched_net"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scopes returns every scope.
|
// Scopes returns every scope.
|
||||||
func Scopes() []string {
|
func Scopes() []string {
|
||||||
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatch}
|
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The windows a counter counts in, as the alerts name them.
|
// The windows a counter counts in, as the alerts name them.
|
||||||
@@ -62,7 +62,7 @@ type Thresholds struct {
|
|||||||
BytesPerHour int64
|
BytesPerHour int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NamedNetblock is a netblock SWWAF_WATCH_NETS names.
|
// NamedNetblock is a netblock SWW_WATCH_NETS names.
|
||||||
type NamedNetblock struct {
|
type NamedNetblock struct {
|
||||||
Name string
|
Name string
|
||||||
Netblock netip.Prefix
|
Netblock netip.Prefix
|
||||||
@@ -70,14 +70,14 @@ type NamedNetblock struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// The thresholds of each scope: SWWAF_ANOMALY_CLIENT_*,
|
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
|
||||||
// SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and
|
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
|
||||||
// SWWAF_WATCH_*.
|
// SWW_WATCH_*.
|
||||||
Client, Net, ASN, Total, Watch Thresholds
|
Client, Net, ASN, Total, Watch Thresholds
|
||||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||||
// client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX).
|
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
|
||||||
NetV4Prefix, NetV6Prefix int
|
NetV4Prefix, NetV6Prefix int
|
||||||
// NamedNetblocks are SWWAF_WATCH_NETS.
|
// NamedNetblocks are SWW_WATCH_NETS.
|
||||||
NamedNetblocks []NamedNetblock
|
NamedNetblocks []NamedNetblock
|
||||||
// Alerts receive the anomaly alerts.
|
// Alerts receive the anomaly alerts.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
@@ -107,13 +107,13 @@ type Request struct {
|
|||||||
Host string
|
Host string
|
||||||
// Client is the client's address, and ClientGroup the client it is
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||||
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
// SWW_IPV6_GROUP_PREFIX its address is in.
|
||||||
Client netip.Addr
|
Client netip.Addr
|
||||||
ClientGroup netip.Prefix
|
ClientGroup netip.Prefix
|
||||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||||
// unknown.
|
// unknown.
|
||||||
ASN, ASName, Country string
|
ASN, ASName, Country string
|
||||||
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them.
|
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
|
||||||
Bytes int64
|
Bytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +257,7 @@ func (c *Counters) scopesOf(r Request) []scope {
|
|||||||
for _, named := range p.NamedNetblocks {
|
for _, named := range p.NamedNetblocks {
|
||||||
if named.Netblock.Contains(client) {
|
if named.Netblock.Contains(client) {
|
||||||
all = append(all, scope{
|
all = append(all, scope{
|
||||||
key{scope: ScopeWatch, netblock: named.Netblock, name: named.Name}, p.Watch,
|
key{scope: ScopeWatchedNet, netblock: named.Netblock, name: named.Name}, p.Watch,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,7 +391,7 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
|
|||||||
detail["asn"] = k.asn
|
detail["asn"] = k.asn
|
||||||
case ScopeTotal:
|
case ScopeTotal:
|
||||||
counted = "the whole service"
|
counted = "the whole service"
|
||||||
default: // watch
|
default: // watched_net
|
||||||
counted = "the named netblock " + k.name + ", " + k.netblock.String()
|
counted = "the named netblock " + k.name + ", " + k.netblock.String()
|
||||||
detail["name"] = k.name
|
detail["name"] = k.name
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-18
@@ -13,7 +13,7 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
|
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
|
||||||
|
|
||||||
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
|
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
|
||||||
@@ -24,16 +24,16 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
|
|||||||
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
rules := defaultRules()
|
params := defaultParams()
|
||||||
rules.MaxBans = 1
|
params.MaxBans = 1
|
||||||
ledger := bans.New(rules)
|
ledger := bans.New(params)
|
||||||
adminsOnly := netip.MustParsePrefix("198.51.100.0/24")
|
adminsOnly := netip.MustParsePrefix("198.51.100.0/24")
|
||||||
both := netip.MustParsePrefix("203.0.113.1/32")
|
both := netip.MustParsePrefix("203.0.113.1/32")
|
||||||
second := netip.MustParsePrefix("203.0.113.2/32")
|
second := netip.MustParsePrefix("203.0.113.2/32")
|
||||||
third := netip.MustParsePrefix("203.0.113.3/32")
|
third := netip.MustParsePrefix("203.0.113.3/32")
|
||||||
|
|
||||||
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
||||||
// then one with an admin's ban before a ban smallwebwaf made: the one
|
// then one with an admin's ban before a ban sww made: the one
|
||||||
// ban counted toward MaxBans.
|
// ban counted toward MaxBans.
|
||||||
ledger.Load([]bans.Ban{
|
ledger.Load([]bans.Ban{
|
||||||
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
||||||
@@ -48,7 +48,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
})
|
})
|
||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
||||||
|
|
||||||
// A new ban drops the ban smallwebwaf made, and only that one.
|
// A new ban drops the ban sww made, and only that one.
|
||||||
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
||||||
|
|
||||||
@@ -64,9 +64,9 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
rules := defaultRules()
|
params := defaultParams()
|
||||||
rules.MaxBans = 2
|
params.MaxBans = 2
|
||||||
ledger := bans.New(rules)
|
ledger := bans.New(params)
|
||||||
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||||
other := netip.MustParsePrefix("203.0.113.2/32")
|
other := netip.MustParsePrefix("203.0.113.2/32")
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
|||||||
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||||
|
|
||||||
// The netblock breaks a limit twice, the second time after its first
|
// The netblock breaks a limit twice, the second time after its first
|
||||||
@@ -114,10 +114,10 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
func TestReasonOfTheBansSwwMakes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
|
|
||||||
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||||
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
|
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
|
||||||
@@ -150,7 +150,7 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
|||||||
Lifted: midnight().Add(10 * time.Minute),
|
Lifted: midnight().Add(10 * time.Minute),
|
||||||
}
|
}
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
ledger.Load([]bans.Ban{lifted})
|
ledger.Load([]bans.Ban{lifted})
|
||||||
|
|
||||||
// While it would still last, it refuses nothing, and a limit broken
|
// While it would still last, it refuses nothing, and a limit broken
|
||||||
@@ -181,7 +181,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
|||||||
|
|
||||||
// A permanent ban for a clear sign of attack, lifted.
|
// A permanent ban for a clear sign of attack, lifted.
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
ledger.Load([]bans.Ban{{
|
ledger.Load([]bans.Ban{{
|
||||||
Netblock: netblock,
|
Netblock: netblock,
|
||||||
Start: midnight(),
|
Start: midnight(),
|
||||||
@@ -211,7 +211,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
|||||||
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||||
bans.Notes{})
|
bans.Notes{})
|
||||||
atStart := bans.Ban{
|
atStart := bans.Ban{
|
||||||
@@ -226,7 +226,7 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
|||||||
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin keeps the ban smallwebwaf made, keeps the one read at the
|
// The admin keeps the ban sww made, keeps the one read at the
|
||||||
// start, and adds one without a cause: that one alone is made.
|
// start, and adds one without a cause: that one alone is made.
|
||||||
kept := made
|
kept := made
|
||||||
kept.Cause = bans.CauseAdmin
|
kept.Cause = bans.CauseAdmin
|
||||||
@@ -246,7 +246,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
|
|
||||||
// An hour's ban for a broken limit.
|
// An hour's ban for a broken limit.
|
||||||
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
@@ -292,7 +292,7 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
|||||||
wide := netip.MustParsePrefix("203.0.113.0/24")
|
wide := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
other := netip.MustParsePrefix("203.0.113.10/32")
|
other := netip.MustParsePrefix("203.0.113.10/32")
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
ledger.Load([]bans.Ban{
|
ledger.Load([]bans.Ban{
|
||||||
// Ended an hour ago.
|
// Ended an hour ago.
|
||||||
{
|
{
|
||||||
@@ -341,3 +341,48 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
|||||||
|
|
||||||
wantChanged(t, ledger, false)
|
wantChanged(t, ledger, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := netip.MustParseAddr("203.0.113.9")
|
||||||
|
ledger := bans.New(defaultParams())
|
||||||
|
|
||||||
|
// The temporary ban is made first, so that its netblock is looked at first.
|
||||||
|
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), bans.Notes{})
|
||||||
|
ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.0/24"), midnight(), time.Time{},
|
||||||
|
"for good")
|
||||||
|
|
||||||
|
wantPermanentBan(t, ledger, client, midnight().Add(time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOlderPermanentBanIsReturnedOverANewerTemporaryBan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
|
ledger := bans.New(defaultParams())
|
||||||
|
|
||||||
|
ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good")
|
||||||
|
ledger.BanForAdmin(netblock, midnight().Add(time.Minute), midnight().Add(time.Hour),
|
||||||
|
"for an hour")
|
||||||
|
|
||||||
|
wantPermanentBan(t, ledger, netblock.Addr(), midnight().Add(2*time.Minute))
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantPermanentBan checks that Find and Check return a permanent ban for
|
||||||
|
// client at now.
|
||||||
|
func wantPermanentBan(
|
||||||
|
t *testing.T, ledger *bans.Ledger, client netip.Addr, now time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for name, look := range map[string]func(netip.Addr, time.Time) (bans.Ban, bool, bool){
|
||||||
|
"Find": ledger.Find,
|
||||||
|
"Check": ledger.Check,
|
||||||
|
} {
|
||||||
|
ban, banned, _ := look(client, now)
|
||||||
|
if !banned || !ban.Permanent() {
|
||||||
|
t.Errorf("%s returned %+v and banned %v, want a permanent ban", name, ban, banned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-28
@@ -1,4 +1,4 @@
|
|||||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
// Package bans is the ban ledger: the bans sww makes on the
|
||||||
// netblocks of clients that break a rate limit, a byte limit or the error
|
// netblocks of clients that break a rate limit, a byte limit or the error
|
||||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
// burst, show a clear sign of attack or are listed by the CrowdSec
|
||||||
// decision list, and
|
// decision list, and
|
||||||
@@ -21,14 +21,14 @@ import (
|
|||||||
|
|
||||||
// The causes of bans.
|
// The causes of bans.
|
||||||
const (
|
const (
|
||||||
// CauseLimit is a ban smallwebwaf made for a broken limit.
|
// CauseLimit is a ban sww made for a broken limit.
|
||||||
CauseLimit = "limit"
|
CauseLimit = "limit"
|
||||||
// CauseAttack is a ban smallwebwaf made for a clear sign of attack.
|
// CauseAttack is a ban sww made for a clear sign of attack.
|
||||||
CauseAttack = "attack"
|
CauseAttack = "attack"
|
||||||
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
// CauseAdmin is a ban an admin made, or one sww made that an
|
||||||
// admin keeps. It is never dropped.
|
// admin keeps. It is never dropped.
|
||||||
CauseAdmin = "admin"
|
CauseAdmin = "admin"
|
||||||
// CauseCrowdSec is a ban smallwebwaf made for a client the CrowdSec
|
// CauseCrowdSec is a ban sww made for a client the CrowdSec
|
||||||
// decision list lists. It ends when CrowdSec's decision does.
|
// decision list lists. It ends when CrowdSec's decision does.
|
||||||
CauseCrowdSec = "crowdsec"
|
CauseCrowdSec = "crowdsec"
|
||||||
)
|
)
|
||||||
@@ -40,8 +40,8 @@ const repeatFactor = 3
|
|||||||
// maxTextBytes is how much of each text in a ban's notes is kept.
|
// maxTextBytes is how much of each text in a ban's notes is kept.
|
||||||
const maxTextBytes = 256
|
const maxTextBytes = 256
|
||||||
|
|
||||||
// Rules are how long a ban lasts, and how many bans are held.
|
// Params are how long a ban lasts, and how many bans are held.
|
||||||
type Rules struct {
|
type Params struct {
|
||||||
// LimitBanDuration is how long a first ban for a broken limit lasts.
|
// LimitBanDuration is how long a first ban for a broken limit lasts.
|
||||||
LimitBanDuration time.Duration
|
LimitBanDuration time.Duration
|
||||||
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
||||||
@@ -70,7 +70,7 @@ type Ban struct {
|
|||||||
Expires time.Time
|
Expires time.Time
|
||||||
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
||||||
Cause string
|
Cause string
|
||||||
// Reason is a short text: for a ban smallwebwaf made, the limit broken,
|
// Reason is a short text: for a ban sww made, the limit broken,
|
||||||
// the rule that matched or the scenario of CrowdSec's decision; for an
|
// the rule that matched or the scenario of CrowdSec's decision; for an
|
||||||
// admin's, what the admin wrote.
|
// admin's, what the admin wrote.
|
||||||
Reason string
|
Reason string
|
||||||
@@ -123,7 +123,7 @@ type Notes struct {
|
|||||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||||
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
||||||
// of the rule file rule that matched, and its target; TrapPath is, for
|
// of the rule file rule that matched, and its target; TrapPath is, for
|
||||||
// one for a request for a path in SWWAF_TRAP_PATHS, that path as the
|
// one for a request for a path in SWW_TRAP_PATHS, that path as the
|
||||||
// client sent it.
|
// client sent it.
|
||||||
RuleID string `json:"rule_id,omitempty"`
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
@@ -180,7 +180,7 @@ type Request struct {
|
|||||||
|
|
||||||
// Ledger holds the bans. It is safe for concurrent use.
|
// Ledger holds the bans. It is safe for concurrent use.
|
||||||
type Ledger struct {
|
type Ledger struct {
|
||||||
rules Rules
|
params Params
|
||||||
// changed receives a value when a ban is made, unless one is waiting
|
// changed receives a value when a ban is made, unless one is waiting
|
||||||
// already.
|
// already.
|
||||||
changed chan struct{}
|
changed chan struct{}
|
||||||
@@ -190,7 +190,7 @@ type Ledger struct {
|
|||||||
// Find make each netblock they find the most recently seen.
|
// Find make each netblock they find the most recently seen.
|
||||||
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
||||||
// held is how many bans netblocks holds whose cause is not CauseAdmin,
|
// held is how many bans netblocks holds whose cause is not CauseAdmin,
|
||||||
// at most rules.MaxBans.
|
// at most params.MaxBans.
|
||||||
held int
|
held int
|
||||||
// made is how many bans have been made since the start, by cause: by
|
// made is how many bans have been made since the start, by cause: by
|
||||||
// the ledger, and by an admin, through BanForAdmin or in an edit of
|
// the ledger, and by an admin, through BanForAdmin or in an edit of
|
||||||
@@ -199,13 +199,13 @@ type Ledger struct {
|
|||||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||||
// netblocks that have been banned. Check looks for a ban at each of
|
// netblocks that have been banned. Check looks for a ban at each of
|
||||||
// them, so that a ban read from bans.json refuses every client in its
|
// them, so that a ban read from bans.json refuses every client in its
|
||||||
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
|
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
|
||||||
// or another length of an IPv6 client's netblock.
|
// or another length of an IPv6 client's netblock.
|
||||||
v4Lengths, v6Lengths []int
|
v4Lengths, v6Lengths []int
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Ledger with no ban yet.
|
// New returns a Ledger with no ban yet.
|
||||||
func New(rules Rules) *Ledger {
|
func New(params Params) *Ledger {
|
||||||
// The ledger drops bans itself, and never those whose cause is
|
// The ledger drops bans itself, and never those whose cause is
|
||||||
// CauseAdmin, however many there are, so the LRU has no limit of its
|
// CauseAdmin, however many there are, so the LRU has no limit of its
|
||||||
// own: it keeps the netblocks in the order they were last seen.
|
// own: it keeps the netblocks in the order they were last seen.
|
||||||
@@ -215,7 +215,7 @@ func New(rules Rules) *Ledger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Ledger{
|
return &Ledger{
|
||||||
rules: rules,
|
params: params,
|
||||||
changed: make(chan struct{}, 1),
|
changed: make(chan struct{}, 1),
|
||||||
netblocks: netblocks,
|
netblocks: netblocks,
|
||||||
made: map[string]int{},
|
made: map[string]int{},
|
||||||
@@ -274,16 +274,27 @@ func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
|||||||
|
|
||||||
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
||||||
// is active at now, or nil when none is. If several are, it returns the
|
// is active at now, or nil when none is. If several are, it returns the
|
||||||
// one that started last. Every ban is looked at, since a ban an admin adds
|
// newest permanent one, or the newest when none is permanent. Every ban is
|
||||||
// to bans.json can start before the netblock's others and outlast them.
|
// looked at, since a ban an admin adds to bans.json can start before the
|
||||||
|
// netblock's others and outlast them.
|
||||||
func activeBan(bans []Ban, now time.Time) *Ban {
|
func activeBan(bans []Ban, now time.Time) *Ban {
|
||||||
|
var newest *Ban
|
||||||
|
|
||||||
for i := len(bans) - 1; i >= 0; i-- {
|
for i := len(bans) - 1; i >= 0; i-- {
|
||||||
if bans[i].ActiveAt(now) {
|
if !bans[i].ActiveAt(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if bans[i].Permanent() {
|
||||||
return &bans[i]
|
return &bans[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if newest == nil {
|
||||||
|
newest = &bans[i]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return newest
|
||||||
}
|
}
|
||||||
|
|
||||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||||
@@ -581,7 +592,7 @@ func (l *Ledger) Load(bans []Ban) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
||||||
// smallwebwaf runs. Each ban in it whose cause is CauseAdmin, and which
|
// sww runs. Each ban in it whose cause is CauseAdmin, and which
|
||||||
// the ledger did not hold, with the same netblock and start, is one the
|
// the ledger did not hold, with the same netblock and start, is one the
|
||||||
// admin made, and is counted among the bans made.
|
// admin made, and is counted among the bans made.
|
||||||
func (l *Ledger) LoadEdit(bans []Ban) {
|
func (l *Ledger) LoadEdit(bans []Ban) {
|
||||||
@@ -743,13 +754,16 @@ func (l *Ledger) markChanged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// active returns the ban active at now on a netblock client is in, or
|
// active returns the ban active at now on a netblock client is in, or
|
||||||
// nil.
|
// nil. If several netblocks have one, it returns a permanent ban when
|
||||||
|
// there is one.
|
||||||
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||||
lengths := l.v6Lengths
|
lengths := l.v6Lengths
|
||||||
if client.Is4() {
|
if client.Is4() {
|
||||||
lengths = l.v4Lengths
|
lengths = l.v4Lengths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var first *Ban
|
||||||
|
|
||||||
for _, length := range lengths {
|
for _, length := range lengths {
|
||||||
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
||||||
if !found {
|
if !found {
|
||||||
@@ -757,12 +771,20 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ban := activeBan(*bans, now)
|
ban := activeBan(*bans, now)
|
||||||
if ban != nil {
|
if ban == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ban.Permanent() {
|
||||||
return ban
|
return ban
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if first == nil {
|
||||||
|
first = ban
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return first
|
||||||
}
|
}
|
||||||
|
|
||||||
// covering returns the bans of each netblock held that client is in,
|
// covering returns the bans of each netblock held that client is in,
|
||||||
@@ -790,7 +812,7 @@ func (l *Ledger) covering(client netip.Addr) []*[]Ban {
|
|||||||
// unless ban's cause is CauseAdmin, which does not count toward MaxBans.
|
// unless ban's cause is CauseAdmin, which does not count toward MaxBans.
|
||||||
func (l *Ledger) add(ban Ban) {
|
func (l *Ledger) add(ban Ban) {
|
||||||
counted := ban.Cause != CauseAdmin
|
counted := ban.Cause != CauseAdmin
|
||||||
if counted && l.held == l.rules.MaxBans {
|
if counted && l.held == l.params.MaxBans {
|
||||||
l.dropOne()
|
l.dropOne()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,7 +847,7 @@ func (l *Ledger) add(ban Ban) {
|
|||||||
// new ban longer. A ban an admin adds to bans.json can start after
|
// new ban longer. A ban an admin adds to bans.json can start after
|
||||||
// another and end before it, so that one is looked for among them all.
|
// another and end before it, so that one is looked for among them all.
|
||||||
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||||
length := l.rules.LimitBanDuration
|
length := l.params.LimitBanDuration
|
||||||
|
|
||||||
var last *Ban
|
var last *Ban
|
||||||
|
|
||||||
@@ -836,18 +858,18 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
|
if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
|
||||||
lastLength := last.Expires.Sub(last.Start)
|
lastLength := last.Expires.Sub(last.Start)
|
||||||
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
||||||
// that it cannot overflow.
|
// that it cannot overflow.
|
||||||
if lastLength > l.rules.MaxBanDuration/repeatFactor {
|
if lastLength > l.params.MaxBanDuration/repeatFactor {
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
length = repeatFactor * lastLength
|
length = repeatFactor * lastLength
|
||||||
}
|
}
|
||||||
|
|
||||||
if length > l.rules.MaxBanDuration {
|
if length > l.params.MaxBanDuration {
|
||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,7 +887,7 @@ func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return now.Add(l.rules.AttackBanDuration)
|
return now.Add(l.params.AttackBanDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dropOne drops the earliest ban whose cause is not CauseAdmin of the
|
// dropOne drops the earliest ban whose cause is not CauseAdmin of the
|
||||||
|
|||||||
+27
-27
@@ -15,7 +15,7 @@ const day = 24 * time.Hour
|
|||||||
func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
now := midnight()
|
now := midnight()
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
@@ -80,9 +80,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
|||||||
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
rules := defaultRules()
|
params := defaultParams()
|
||||||
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
|
params.LimitBanDuration = params.MaxBanDuration + time.Hour
|
||||||
ledger := bans.New(rules)
|
ledger := bans.New(params)
|
||||||
|
|
||||||
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
||||||
bans.Notes{})
|
bans.Notes{})
|
||||||
@@ -97,9 +97,9 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
|||||||
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
|
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
|
||||||
// hours, is within the maximum, and three times as long would not fit
|
// hours, is within the maximum, and three times as long would not fit
|
||||||
// in a time.Duration. The 15th is permanent.
|
// in a time.Duration. The 15th is permanent.
|
||||||
rules := defaultRules()
|
params := defaultParams()
|
||||||
rules.MaxBanDuration = 100000 * day
|
params.MaxBanDuration = 100000 * day
|
||||||
ledger := bans.New(rules)
|
ledger := bans.New(params)
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
now := midnight()
|
now := midnight()
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
|||||||
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
@@ -146,7 +146,7 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
|||||||
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
|||||||
func TestFindCountsNothing(t *testing.T) {
|
func TestFindCountsNothing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||||
|
|
||||||
@@ -200,9 +200,9 @@ func TestFindCountsNothing(t *testing.T) {
|
|||||||
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
rules := defaultRules()
|
params := defaultParams()
|
||||||
rules.MaxBans = 3
|
params.MaxBans = 3
|
||||||
ledger := bans.New(rules)
|
ledger := bans.New(params)
|
||||||
a := netip.MustParsePrefix("203.0.113.1/32")
|
a := netip.MustParsePrefix("203.0.113.1/32")
|
||||||
b := netip.MustParsePrefix("203.0.113.2/32")
|
b := netip.MustParsePrefix("203.0.113.2/32")
|
||||||
c := netip.MustParsePrefix("203.0.113.3/32")
|
c := netip.MustParsePrefix("203.0.113.3/32")
|
||||||
@@ -239,9 +239,9 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
|||||||
|
|
||||||
// With room for one ban, the netblock's ended ban goes to make room for
|
// With room for one ban, the netblock's ended ban goes to make room for
|
||||||
// its new one, whose notes still count it.
|
// its new one, whose notes still count it.
|
||||||
rules := defaultRules()
|
params := defaultParams()
|
||||||
rules.MaxBans = 1
|
params.MaxBans = 1
|
||||||
ledger := bans.New(rules)
|
ledger := bans.New(params)
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
@@ -258,7 +258,7 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
|||||||
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
|||||||
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
// A ban for a broken limit before does not count.
|
// A ban for a broken limit before does not count.
|
||||||
@@ -336,7 +336,7 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
|||||||
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
wantChanged(t, ledger, true)
|
wantChanged(t, ledger, true)
|
||||||
@@ -380,7 +380,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
|||||||
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
now := midnight()
|
now := midnight()
|
||||||
|
|
||||||
@@ -414,7 +414,7 @@ func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
|||||||
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
// Three times the seven days would be permanent; a limit broken as the
|
// Three times the seven days would be permanent; a limit broken as the
|
||||||
@@ -436,7 +436,7 @@ func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
|||||||
func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
long := strings.Repeat("a", 300)
|
long := strings.Repeat("a", 300)
|
||||||
request := bans.Request{
|
request := bans.Request{
|
||||||
@@ -458,7 +458,7 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
|||||||
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
other := netip.MustParsePrefix("198.51.100.7/32")
|
other := netip.MustParsePrefix("198.51.100.7/32")
|
||||||
|
|
||||||
@@ -494,9 +494,9 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultRules are the rules at the settings' defaults.
|
// defaultParams are the params at the settings' defaults.
|
||||||
func defaultRules() bans.Rules {
|
func defaultParams() bans.Params {
|
||||||
return bans.Rules{
|
return bans.Params{
|
||||||
LimitBanDuration: time.Hour,
|
LimitBanDuration: time.Hour,
|
||||||
LimitBanRepeatWindow: day,
|
LimitBanRepeatWindow: day,
|
||||||
MaxBanDuration: 7 * day,
|
MaxBanDuration: 7 * day,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const scenario = "crowdsecurity/ssh-bf"
|
|||||||
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
|
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
expires := midnight().Add(4 * time.Hour)
|
expires := midnight().Add(4 * time.Hour)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
|
|||||||
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
|
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
expires := midnight().Add(4 * time.Hour)
|
expires := midnight().Add(4 * time.Hour)
|
||||||
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
|
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
|
||||||
@@ -72,7 +72,7 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
|
|||||||
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
// Three times the three days would be permanent; a limit broken as the
|
// Three times the three days would be permanent; a limit broken as the
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
func TestChangedAfterABanIsMade(t *testing.T) {
|
func TestChangedAfterABanIsMade(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
|
|
||||||
wantChanged(t, ledger, false)
|
wantChanged(t, ledger, false)
|
||||||
@@ -38,7 +38,7 @@ func TestChangedAfterABanIsMade(t *testing.T) {
|
|||||||
func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
v6 := netip.MustParsePrefix("2001:db8::/64")
|
v6 := netip.MustParsePrefix("2001:db8::/64")
|
||||||
high := netip.MustParsePrefix("203.0.113.10/32")
|
high := netip.MustParsePrefix("203.0.113.10/32")
|
||||||
low := netip.MustParsePrefix("203.0.113.9/32")
|
low := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
@@ -67,14 +67,14 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
|||||||
func TestLoadedBansCarryOn(t *testing.T) {
|
func TestLoadedBansCarryOn(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
before := bans.New(defaultRules())
|
before := bans.New(defaultParams())
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
||||||
|
|
||||||
// Loaded into a new ledger, as across a restart, the ban still refuses
|
// Loaded into a new ledger, as across a restart, the ban still refuses
|
||||||
// while it lasts, and once it has ended a broken limit bans for three
|
// while it lasts, and once it has ended a broken limit bans for three
|
||||||
// times as long, with the loaded ban counted among the earlier ones.
|
// times as long, with the loaded ban counted among the earlier ones.
|
||||||
after := bans.New(defaultRules())
|
after := bans.New(defaultParams())
|
||||||
after.Load(before.Snapshot())
|
after.Load(before.Snapshot())
|
||||||
|
|
||||||
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
||||||
@@ -96,7 +96,7 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
|
|||||||
// Two entries as an admin might write them, with addresses not masked
|
// Two entries as an admin might write them, with addresses not masked
|
||||||
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
|
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
|
||||||
// ban covers, beside a ban the ledger makes on one IPv4 address.
|
// ban covers, beside a ban the ledger makes on one IPv4 address.
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
ledger.Load([]bans.Ban{
|
ledger.Load([]bans.Ban{
|
||||||
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
|
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
|
||||||
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
|
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
|
||||||
@@ -145,7 +145,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
|||||||
Expires: midnight().Add(time.Hour),
|
Expires: midnight().Add(time.Hour),
|
||||||
}
|
}
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
ledger.Load([]bans.Ban{permanent, ended})
|
ledger.Load([]bans.Ban{permanent, ended})
|
||||||
|
|
||||||
now := midnight().Add(2 * time.Hour)
|
now := midnight().Add(2 * time.Hour)
|
||||||
@@ -173,7 +173,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
|||||||
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's
|
// A 9-hour ban sww made, the third in a row, and an admin's
|
||||||
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
nineHours := bans.Ban{
|
nineHours := bans.Ban{
|
||||||
@@ -189,7 +189,7 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
|||||||
Expires: midnight().Add(2 * time.Hour),
|
Expires: midnight().Add(2 * time.Hour),
|
||||||
}
|
}
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
ledger.Load([]bans.Ban{nineHours, admins})
|
ledger.Load([]bans.Ban{nineHours, admins})
|
||||||
|
|
||||||
// Once both have ended, a limit broken within the repeat window bans
|
// Once both have ended, a limit broken within the repeat window bans
|
||||||
@@ -219,9 +219,9 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
|||||||
Cause: bans.CauseLimit,
|
Cause: bans.CauseLimit,
|
||||||
}
|
}
|
||||||
|
|
||||||
rules := defaultRules()
|
params := defaultParams()
|
||||||
rules.MaxBans = 1
|
params.MaxBans = 1
|
||||||
ledger := bans.New(rules)
|
ledger := bans.New(params)
|
||||||
ledger.Load([]bans.Ban{later, earlier})
|
ledger.Load([]bans.Ban{later, earlier})
|
||||||
|
|
||||||
held := ledger.Snapshot()
|
held := ledger.Snapshot()
|
||||||
@@ -235,9 +235,9 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
|
|||||||
|
|
||||||
// Room for three bans, so that the second load, were it added to the
|
// Room for three bans, so that the second load, were it added to the
|
||||||
// two bans held, would drop none of them to make room.
|
// two bans held, would drop none of them to make room.
|
||||||
rules := defaultRules()
|
params := defaultParams()
|
||||||
rules.MaxBans = 3
|
params.MaxBans = 3
|
||||||
ledger := bans.New(rules)
|
ledger := bans.New(params)
|
||||||
kept := bans.Ban{
|
kept := bans.Ban{
|
||||||
Netblock: netip.MustParsePrefix("2001:db8::/64"),
|
Netblock: netip.MustParsePrefix("2001:db8::/64"),
|
||||||
Start: midnight(),
|
Start: midnight(),
|
||||||
@@ -285,7 +285,7 @@ func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultParams())
|
||||||
ledger.Load([]bans.Ban{ban})
|
ledger.Load([]bans.Ban{ban})
|
||||||
|
|
||||||
cut := long[:256]
|
cut := long[:256]
|
||||||
|
|||||||
+684
-296
File diff suppressed because it is too large.
Load diff
+433
-175
@@ -23,99 +23,100 @@ import (
|
|||||||
|
|
||||||
// The settings, by name.
|
// The settings, by name.
|
||||||
const (
|
const (
|
||||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
listenAddr = "SWW_LISTEN_ADDR"
|
||||||
mode = "SWWAF_MODE"
|
mode = "SWW_MODE"
|
||||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||||
allowNets = "SWWAF_ALLOW_NETS"
|
allowNets = "SWW_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWWAF_DENY_NETS"
|
denyNets = "SWW_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWWAF_BYTES_COUNT"
|
bytesCount = "SWW_BYTES_COUNT"
|
||||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
tagLists = "SWW_TAG_LISTS"
|
||||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
tagDeny = "SWW_TAG_DENY"
|
||||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||||
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH"
|
blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
|
||||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
dnsblZones = "SWW_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||||
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
|
||||||
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
|
||||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
reputationAction = "SWW_REPUTATION_ACTION"
|
||||||
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
|
||||||
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
|
||||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
||||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
banResponse = "SWWAF_BAN_RESPONSE"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||||
maxBans = "SWWAF_MAX_BANS"
|
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
maxBans = "SWW_MAX_BANS"
|
||||||
stateDir = "SWWAF_STATE_DIR"
|
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
stateDir = "SWW_STATE_DIR"
|
||||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
||||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
||||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
instanceName = "SWWAF_INSTANCE_NAME"
|
metricsTopN = "SWW_METRICS_TOP_N"
|
||||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
logLevel = "SWWAF_LOG_LEVEL"
|
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||||
rulesDir = "SWWAF_RULES_DIR"
|
logLevel = "SWW_LOG_LEVEL"
|
||||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
wafMode = "SWWAF_WAF_MODE"
|
rulesEnabled = "SWW_RULES_ENABLED"
|
||||||
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
wafMode = "SWW_WAF_MODE"
|
||||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
|
||||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
exclusionsFile = "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
trapPaths = "SWWAF_TRAP_PATHS"
|
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||||
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
|
||||||
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
|
||||||
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
|
||||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
|
||||||
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS"
|
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||||
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL"
|
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
|
||||||
alertNtfyURL = "SWWAF_ALERT_NTFY_URL"
|
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
|
||||||
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
alertNtfyURL = "SWW_ALERT_NTFY_URL"
|
||||||
alertEvents = "SWWAF_ALERT_EVENTS"
|
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||||
alertCooldown = "SWWAF_ALERT_COOLDOWN"
|
alertEvents = "SWW_ALERT_EVENTS"
|
||||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
alertCooldown = "SWW_ALERT_COOLDOWN"
|
||||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||||
watchNets = "SWWAF_WATCH_NETS"
|
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||||
|
watchNets = "SWW_WATCH_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
||||||
// followed by each of the four ends.
|
// followed by each of the four ends.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWWAF_ANOMALY_NET_"
|
anomalyNet = "SWW_ANOMALY_NET_"
|
||||||
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||||
watch = "SWWAF_WATCH_"
|
watch = "SWW_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -156,21 +157,21 @@ func loggedAnomalyDefaults() map[string]string {
|
|||||||
return logged
|
return logged
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and
|
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
|
||||||
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN.
|
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
|
||||||
const (
|
const (
|
||||||
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," +
|
||||||
"source_failure,file_error"
|
"source_failure,file_error"
|
||||||
defaultAlertCooldown = "15m"
|
defaultAlertCooldown = "15m"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
|
||||||
const defaultReputationCacheTTL = "24h"
|
const defaultReputationCacheTTL = "24h"
|
||||||
|
|
||||||
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES.
|
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
|
||||||
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
||||||
|
|
||||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
|
||||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||||
"content-type,origin,range"
|
"content-type,origin,range"
|
||||||
|
|
||||||
@@ -195,10 +196,10 @@ const (
|
|||||||
otherToken = "fedcba9876543210fedcba9876543210"
|
otherToken = "fedcba9876543210fedcba9876543210"
|
||||||
)
|
)
|
||||||
|
|
||||||
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
|
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and
|
||||||
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
|
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
|
||||||
const (
|
const (
|
||||||
instance = "fsn1app1/gitea"
|
instance = "fsn1app1/blog"
|
||||||
remoteURL = "syslog+udp://192.0.2.1:514"
|
remoteURL = "syslog+udp://192.0.2.1:514"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -208,7 +209,10 @@ const off = "off"
|
|||||||
// enabled is true, as a setting's value.
|
// enabled is true, as a setting's value.
|
||||||
const enabled = "true"
|
const enabled = "true"
|
||||||
|
|
||||||
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and
|
// disabled is false, as a setting's value.
|
||||||
|
const disabled = "false"
|
||||||
|
|
||||||
|
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
|
||||||
// fileSource the source that is the lookup database.
|
// fileSource the source that is the lookup database.
|
||||||
const (
|
const (
|
||||||
defaultLookupSource = "geojs"
|
defaultLookupSource = "geojs"
|
||||||
@@ -255,12 +259,12 @@ func TestDefaults(t *testing.T) {
|
|||||||
AttackBanDuration: 7 * 24 * time.Hour,
|
AttackBanDuration: 7 * 24 * time.Hour,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
BanScopeV4Prefix: 32,
|
BanScopeV4Prefix: 32,
|
||||||
StateDir: "/var/lib/smallwebwaf",
|
StateDir: "/var/lib/sww",
|
||||||
StateWriteDelay: 10 * time.Second,
|
StateWriteDelay: 10 * time.Second,
|
||||||
StateCounterInterval: 15 * time.Minute,
|
StateCounterInterval: 15 * time.Minute,
|
||||||
MetricsToken: "",
|
MetricsToken: "",
|
||||||
MetricsTopN: 50,
|
MetricsTopN: 50,
|
||||||
RulesDir: "/etc/smallwebwaf/rules.d",
|
RulesDir: "/etc/sww/rules.d",
|
||||||
RulesEnabled: true,
|
RulesEnabled: true,
|
||||||
})
|
})
|
||||||
wantLookupSettings(t, cfg, config.Config{
|
wantLookupSettings(t, cfg, config.Config{
|
||||||
@@ -274,9 +278,13 @@ func TestDefaults(t *testing.T) {
|
|||||||
wantNetblocks(t, cfg.AllowNets)
|
wantNetblocks(t, cfg.AllowNets)
|
||||||
wantNetblocks(t, cfg.RateLimitExemptNets)
|
wantNetblocks(t, cfg.RateLimitExemptNets)
|
||||||
wantNetblocks(t, cfg.DenyNets)
|
wantNetblocks(t, cfg.DenyNets)
|
||||||
wantCountries(t, deniedCountries, cfg.DeniedCountries)
|
|
||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
||||||
|
|
||||||
|
if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 {
|
||||||
|
t.Errorf("tag settings %v, %v and %v by default, want all empty",
|
||||||
|
cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent)
|
||||||
|
}
|
||||||
|
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
||||||
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
||||||
@@ -327,7 +335,6 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
rateLimitPerMinute: "60",
|
rateLimitPerMinute: "60",
|
||||||
rateLimitPerHour: "600",
|
rateLimitPerHour: "600",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
deniedCountries: "cn, RU,kp,Xk",
|
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
banResponse: "429",
|
banResponse: "429",
|
||||||
limitBanDuration: "15m",
|
limitBanDuration: "15m",
|
||||||
@@ -342,7 +349,7 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "10",
|
metricsTopN: "10",
|
||||||
rulesDir: "/srv/waf-rules",
|
rulesDir: "/srv/waf-rules",
|
||||||
rulesEnabled: "false",
|
rulesEnabled: disabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
wantSettings(t, cfg, config.Config{
|
wantSettings(t, cfg, config.Config{
|
||||||
@@ -370,7 +377,6 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
||||||
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
||||||
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
||||||
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
|
|
||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,14 +546,14 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
environment{
|
environment{
|
||||||
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
||||||
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
||||||
wafBodyLimit: "32K", exclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf",
|
wafBodyLimit: "32K", exclusionsFile: "/etc/sww/crs-exclusions.conf",
|
||||||
},
|
},
|
||||||
config.Config{
|
config.Config{
|
||||||
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
||||||
WAFDisabledRules: []int{942100, 920350},
|
WAFDisabledRules: []int{942100, 920350},
|
||||||
WAFExemptPaths: []string{"/api/", "/static/"},
|
WAFExemptPaths: []string{"/api/", "/static/"},
|
||||||
WAFBodyLimit: 32 << 10,
|
WAFBodyLimit: 32 << 10,
|
||||||
CoreRuleSetExclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf",
|
CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -591,7 +597,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
|||||||
const (
|
const (
|
||||||
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
||||||
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
||||||
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off"
|
"the Core Rule Set up and of sww's own, which cannot be switched off"
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
for _, tc := range []struct{ name, value, want string }{
|
||||||
@@ -630,10 +636,11 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
|||||||
wafBodyLimit, "128KB",
|
wafBodyLimit, "128KB",
|
||||||
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
||||||
},
|
},
|
||||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most servewaf gets of a body`},
|
{wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` +
|
||||||
|
`gets of a body`},
|
||||||
{
|
{
|
||||||
wafBodyLimit, "65537",
|
wafBodyLimit, "65537",
|
||||||
`"65537" is more than 64K, the most servewaf gets of a body`,
|
`"65537" is more than 64K, the most discriminator gets of a body`,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
@@ -653,11 +660,11 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{
|
cfg := fromEnvironment(t, environment{
|
||||||
instanceName: "fsn1app1/gitea",
|
instanceName: "fsn1app1/blog",
|
||||||
logRequestHeaders: " Accept , X-Custom",
|
logRequestHeaders: " Accept , X-Custom",
|
||||||
})
|
})
|
||||||
|
|
||||||
if cfg.InstanceName != "fsn1app1/gitea" ||
|
if cfg.InstanceName != "fsn1app1/blog" ||
|
||||||
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
|
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
|
||||||
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
|
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
|
||||||
logRequestHeaders, cfg.LogRequestHeaders)
|
logRequestHeaders, cfg.LogRequestHeaders)
|
||||||
@@ -800,7 +807,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
|
|||||||
|
|
||||||
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
||||||
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
||||||
`without a space, such as gitea`
|
`without a space, such as blog`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -829,7 +836,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
|||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
|
|
||||||
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||||
`characters without a space, such as gitea`
|
`characters without a space, such as blog`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -859,7 +866,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
|
|||||||
const (
|
const (
|
||||||
webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
||||||
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||||
ntfy = "https://ntfy.example/smallwebwaf-alerts"
|
ntfy = "https://ntfy.example/sww-alerts"
|
||||||
token = "tk_0123456789abcdefghijklmnopq"
|
token = "tk_0123456789abcdefghijklmnopq"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -907,17 +914,17 @@ func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantStartStopped(t, []struct{ name, value string }{
|
wantStartStopped(t, []struct{ name, value string }{
|
||||||
{alertWebhookURL, "alerts.example/smallwebwaf"},
|
{alertWebhookURL, "alerts.example/sww"},
|
||||||
{alertWebhookURL, "ftp://alerts.example/"},
|
{alertWebhookURL, "ftp://alerts.example/"},
|
||||||
{alertWebhookURL, "https:///smallwebwaf"},
|
{alertWebhookURL, "https:///sww"},
|
||||||
{alertWebhookURL, "https://user:password@alerts.example/"},
|
{alertWebhookURL, "https://user:password@alerts.example/"},
|
||||||
{alertWebhookURL, "https://alerts.example/#top"},
|
{alertWebhookURL, "https://alerts.example/#top"},
|
||||||
{alertWebhookURL, "https://alerts.example:0/"},
|
{alertWebhookURL, "https://alerts.example:0/"},
|
||||||
{alertWebhookURL, "https://alerts.example:65536/"},
|
{alertWebhookURL, "https://alerts.example:65536/"},
|
||||||
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
|
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
|
||||||
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
|
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
|
||||||
{alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"},
|
{alertNtfyURL, "ntfy://ntfy.example/sww-alerts"},
|
||||||
{alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"},
|
{alertNtfyURL, "https://ntfy.example/sww-alerts#top"},
|
||||||
{alertWebhookHeaders, "Authorization"},
|
{alertWebhookHeaders, "Authorization"},
|
||||||
{alertWebhookHeaders, "X Team:ops"},
|
{alertWebhookHeaders, "X Team:ops"},
|
||||||
{alertWebhookHeaders, ":ops"},
|
{alertWebhookHeaders, ":ops"},
|
||||||
@@ -997,7 +1004,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
|||||||
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
||||||
|
|
||||||
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
||||||
"a fragment, such as https://alerts.example/smallwebwaf"
|
"a fragment, such as https://alerts.example/sww"
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -1011,7 +1018,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
|||||||
|
|
||||||
cfg := fromEnvironment(t, environment{
|
cfg := fromEnvironment(t, environment{
|
||||||
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
||||||
alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||||
alertNtfyToken: token,
|
alertNtfyToken: token,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1030,7 +1037,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, secret := range []string{"T0123", "smallwebwaf-alerts", token} {
|
for _, secret := range []string{"T0123", "sww-alerts", token} {
|
||||||
if strings.Contains(logged, secret) {
|
if strings.Contains(logged, secret) {
|
||||||
t.Errorf("%s in the settings logged: %s", secret, logged)
|
t.Errorf("%s in the settings logged: %s", secret, logged)
|
||||||
}
|
}
|
||||||
@@ -1061,7 +1068,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
|
|||||||
const name = "fsn1app1\r"
|
const name = "fsn1app1\r"
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
|
|
||||||
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
||||||
@@ -1096,15 +1103,15 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
deniedCountries: "cn,ru",
|
tagDeny: "country=cn,country=ru",
|
||||||
allowedCountries: "de,RU",
|
allowedCountries: "de,RU",
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ru on both country lists was accepted")
|
t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
||||||
@@ -1175,12 +1182,11 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, value := range map[string]string{
|
for name, value := range map[string]string{
|
||||||
deniedCountries: "kp",
|
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
addLookupHeaders: enabled,
|
addLookupHeaders: enabled,
|
||||||
|
tagDeny: "country=KP",
|
||||||
|
tagLimitPercent: "asn=64496:50",
|
||||||
asnLimitPercentURL: asnURL,
|
asnLimitPercentURL: asnURL,
|
||||||
asnLimitPercent: "AS64496:50",
|
|
||||||
countryLimitPercent: "cn:25",
|
|
||||||
asnBytesPercent: "AS64496:50",
|
asnBytesPercent: "AS64496:50",
|
||||||
countryBytesPercent: "cn:25",
|
countryBytesPercent: "cn:25",
|
||||||
unknownLimitPercent: "99",
|
unknownLimitPercent: "99",
|
||||||
@@ -1203,12 +1209,13 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set empty, the lists need nothing looked up, and nor does
|
// Set empty, the lists need nothing looked up, and nor does
|
||||||
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
|
||||||
// threshold per AS number that is off, any other anomaly threshold, or
|
// lookup does not give, an anomaly threshold per AS number that is off,
|
||||||
// a blocklist.
|
// any other anomaly threshold, or a blocklist.
|
||||||
env := environment{
|
env := environment{
|
||||||
lookupSource: off, deniedCountries: "", allowedCountries: "",
|
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
|
||||||
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
|
tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt",
|
||||||
|
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
|
||||||
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
||||||
blocklistURLs: dropURL,
|
blocklistURLs: dropURL,
|
||||||
}
|
}
|
||||||
@@ -1354,34 +1361,251 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// torExitTag, zenTag and exploitTag are tags these tests name.
|
||||||
|
const (
|
||||||
|
torExitTag = "torexit"
|
||||||
|
zenTag = "dnsbl=zen.example"
|
||||||
|
exploitTag = "exploitsignature"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTagSettingsAsSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const torURL = "https://example.com/tor.txt"
|
||||||
|
|
||||||
|
// Names and tags in either case, an AS number with leading zeros, and a
|
||||||
|
// name holding a =.
|
||||||
|
cfg := fromEnvironment(t, environment{
|
||||||
|
tagLists: "TorExit=" + torURL +
|
||||||
|
", vpn=mullvad=https://example.com/mullvad.txt",
|
||||||
|
dnsblZones: "Zen.example",
|
||||||
|
tagDeny: "TorExit, Country=kp, asn=014061",
|
||||||
|
tagLimitPercent: "vpn=mullvad :50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantLists := []config.TagList{
|
||||||
|
{Name: torExitTag, URL: torURL},
|
||||||
|
{Name: "vpn=mullvad", URL: "https://example.com/mullvad.txt"},
|
||||||
|
}
|
||||||
|
if !slices.Equal(cfg.TagLists, wantLists) {
|
||||||
|
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantDeny := []string{torExitTag, "country=KP", "asn=14061"}
|
||||||
|
if !slices.Equal(cfg.TagDeny, wantDeny) {
|
||||||
|
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercents := map[string]int64{
|
||||||
|
"vpn=mullvad": 50, "asn=14061": 25, "country=CN": 1, zenTag: 99,
|
||||||
|
}
|
||||||
|
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
|
||||||
|
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteWatchURLAndPrefixTags(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const rw = "https://routewatch.example"
|
||||||
|
|
||||||
|
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
|
||||||
|
t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := fromEnvironment(t, environment{
|
||||||
|
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
|
||||||
|
tagLimitPercent: "prefix=192.0.2.0/24:50",
|
||||||
|
})
|
||||||
|
wantDeny := []string{"prefix=2001:db8::/32"}
|
||||||
|
|
||||||
|
if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) ||
|
||||||
|
cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 {
|
||||||
|
t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
|
||||||
|
fromEnvironment(t, environment{
|
||||||
|
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, stopped := range []struct {
|
||||||
|
env environment
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{environment{routeWatchURL: "ftp://routewatch.example"}, routeWatchURL},
|
||||||
|
{environment{routeWatchURL: "routewatch.example"}, routeWatchURL},
|
||||||
|
{environment{tagDeny: "prefix=192.0.2.0/24"}, tagDeny},
|
||||||
|
{environment{routeWatchURL: rw, tagDeny: "prefix=nowhere"}, tagDeny},
|
||||||
|
{environment{routeWatchURL: rw, tagDeny: "prefix=192.0.2.5/24"}, tagDeny},
|
||||||
|
{
|
||||||
|
environment{routeWatchURL: rw, tagLimitPercent: "prefix=192.0.2.0/33:50"},
|
||||||
|
tagLimitPercent,
|
||||||
|
},
|
||||||
|
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
|
||||||
|
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
|
||||||
|
} {
|
||||||
|
_, err := config.FromEnvironment(stopped.env.lookupEnv)
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
|
||||||
|
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
|
||||||
|
_, err := config.FromEnvironment(badURL.lookupEnv)
|
||||||
|
|
||||||
|
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
|
||||||
|
"without a user or a fragment, such as https://routewatch.example"
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
list = "torexit=https://example.com/tor.txt"
|
||||||
|
key = "abc123"
|
||||||
|
zone = key + ".xbl.dq.spamhaus.net"
|
||||||
|
masked = "********.xbl.dq.spamhaus.net"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env environment
|
||||||
|
want string // "" is accepted
|
||||||
|
}{
|
||||||
|
{"a list's name", environment{tagLists: list, tagDeny: torExitTag}, ""},
|
||||||
|
{
|
||||||
|
"a mistyped list's name", environment{tagLists: list, tagDeny: "torexti"},
|
||||||
|
`"torexti"`,
|
||||||
|
},
|
||||||
|
{"a masked zone", environment{dnsblZones: zone, tagDeny: "dnsbl=" + masked}, ""},
|
||||||
|
{
|
||||||
|
"a zone with its key", environment{dnsblZones: zone, tagDeny: "dnsbl=" + zone},
|
||||||
|
masked,
|
||||||
|
},
|
||||||
|
{"another zone", environment{dnsblZones: zone, tagDeny: zenTag}, zenTag},
|
||||||
|
{
|
||||||
|
"abuseipdb with a key",
|
||||||
|
environment{abuseIPDBKey: "k", tagLimitPercent: "abuseipdb:50"}, "",
|
||||||
|
},
|
||||||
|
{"crowdsec, though a list has its name", environment{
|
||||||
|
tagLists: "crowdsec=https://example.com/c.txt", tagDeny: "crowdsec",
|
||||||
|
}, `"crowdsec" cannot be used`},
|
||||||
|
{exploitTag, environment{tagDeny: exploitTag}, ""},
|
||||||
|
{
|
||||||
|
exploitTag + " with rule files off",
|
||||||
|
environment{tagDeny: exploitTag, rulesEnabled: disabled}, "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exploitTag + " with the Core Rule Set off",
|
||||||
|
environment{tagDeny: exploitTag, wafMode: off}, "",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case tc.want == "" && err != nil:
|
||||||
|
t.Errorf("error %v, want none", err)
|
||||||
|
case tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)):
|
||||||
|
t.Errorf("error %v, want one holding %s", err, tc.want)
|
||||||
|
case err != nil && strings.Contains(err.Error(), key):
|
||||||
|
t.Errorf("error %v shows the zone's key", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
tag = "dnsbl=" + spamhaus
|
||||||
|
emptyItem = "has an empty item in its list"
|
||||||
|
twice = `"` + spamhausMasked + `" is listed twice`
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct{ name, value, want string }{
|
||||||
|
{tagDeny, tag + "," + tag, twice},
|
||||||
|
{tagDeny, tag + ",,", emptyItem},
|
||||||
|
{
|
||||||
|
tagLimitPercent, tag,
|
||||||
|
`"` + spamhausMasked + `" is not a tag, : and a percentage, ` +
|
||||||
|
"such as vpn:50 or asn=14061:25",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tagLimitPercent, "50:" + tag,
|
||||||
|
`"` + spamhausMasked + `" is not a percentage from 1 to 99`,
|
||||||
|
},
|
||||||
|
{tagLimitPercent, tag + ":50," + tag + " :25", twice},
|
||||||
|
{tagLimitPercent, tag + ":50,,", emptyItem},
|
||||||
|
} {
|
||||||
|
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||||
|
|
||||||
|
want := tc.name + ": " + tc.want
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("%s=%s gave the error %v, want %s", tc.name, tc.value, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const lapi = "http://172.17.0.1:8080"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, listURL string
|
||||||
|
env environment
|
||||||
|
}{
|
||||||
|
{"a blocklist", dropURL, environment{blocklistURLs: dropURL}},
|
||||||
|
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
|
||||||
|
{
|
||||||
|
"the CrowdSec decision list", lapi + "/v1/decisions",
|
||||||
|
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tc.env[tagLists] = "dup=" + tc.listURL
|
||||||
|
|
||||||
|
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
|
||||||
|
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
|
||||||
|
|
||||||
|
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBiasedThresholdsAsSet(t *testing.T) {
|
func TestBiasedThresholdsAsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{})
|
cfg := fromEnvironment(t, environment{})
|
||||||
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
|
if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
||||||
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
|
||||||
cfg.UnknownLimitPercent != 100 {
|
cfg.UnknownLimitPercent != 100 {
|
||||||
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
|
t.Errorf("biased thresholds %v, %v and %d by default, "+
|
||||||
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
|
"want two empty lists and 100",
|
||||||
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AS numbers and countries in either case, an AS number with leading
|
// AS numbers and countries in either case, an AS number with leading
|
||||||
// zeros, 0 and 100.
|
// zeros, 0 and 100.
|
||||||
cfg = fromEnvironment(t, environment{
|
cfg = fromEnvironment(t, environment{
|
||||||
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
|
|
||||||
countryLimitPercent: "cn:25,RU:50",
|
|
||||||
asnBytesPercent: "as16276:75",
|
asnBytesPercent: "as16276:75",
|
||||||
countryBytesPercent: "ru:10",
|
countryBytesPercent: "ru:10",
|
||||||
unknownLimitPercent: "0",
|
unknownLimitPercent: "0",
|
||||||
})
|
})
|
||||||
|
|
||||||
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
||||||
asnLimitPercent: {
|
|
||||||
cfg.ASNLimitPercent,
|
|
||||||
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
|
|
||||||
},
|
|
||||||
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
|
|
||||||
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
||||||
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
||||||
} {
|
} {
|
||||||
@@ -1405,16 +1629,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
for _, tc := range []struct{ name, value, want string }{
|
||||||
{asnLimitPercent, "14061:50", `"14061"` + notASN},
|
{asnBytesPercent, "14061:50", `"14061"` + notASN},
|
||||||
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
{asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
||||||
{asnLimitPercent, "AS14061", `"AS14061"` + notItem},
|
{asnBytesPercent, "AS14061", `"AS14061"` + notItem},
|
||||||
{asnLimitPercent, "AS14061:101", `"101"` + notPercent},
|
{asnBytesPercent, "AS14061:101", `"101"` + notPercent},
|
||||||
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
{asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
||||||
{
|
{
|
||||||
countryLimitPercent, "nk:25",
|
countryBytesPercent, "nk:25",
|
||||||
`"nk" is not a two-letter country code such as de or kp`,
|
`"nk" is not a two-letter country code such as de or kp`,
|
||||||
},
|
},
|
||||||
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
{countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
||||||
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
||||||
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
||||||
{unknownLimitPercent, "101", `"101"` + notPercent},
|
{unknownLimitPercent, "101", `"101"` + notPercent},
|
||||||
@@ -1441,7 +1665,7 @@ const (
|
|||||||
asnURL = "https://lists.example/asn.txt"
|
asnURL = "https://lists.example/asn.txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them.
|
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
|
||||||
const (
|
const (
|
||||||
actionDeny = "deny"
|
actionDeny = "deny"
|
||||||
actionLimit = "limit"
|
actionLimit = "limit"
|
||||||
@@ -1537,7 +1761,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
|||||||
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
|
|
||||||
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too`
|
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -1840,7 +2064,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
||||||
"such as http://172.17.0.1:8080"
|
"such as http://172.17.0.1:8080"
|
||||||
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
||||||
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too`
|
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -2002,23 +2226,37 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
||||||
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
||||||
{addLookupHeaders, "yes"},
|
{addLookupHeaders, "yes"},
|
||||||
{deniedCountries, "nk"},
|
{allowedCountries, "nk"},
|
||||||
{deniedCountries, "kp,,ir"},
|
{allowedCountries, "kp,,ir"},
|
||||||
{deniedCountries, "prk"},
|
{allowedCountries, "prk"},
|
||||||
{deniedCountries, "408"},
|
{allowedCountries, "408"},
|
||||||
{deniedCountries, "k"},
|
{allowedCountries, "k"},
|
||||||
{deniedCountries, "eu"},
|
{allowedCountries, "eu"},
|
||||||
{deniedCountries, "un"},
|
{allowedCountries, "un"},
|
||||||
{deniedCountries, "su"},
|
{allowedCountries, "su"},
|
||||||
{allowedCountries, "ac"},
|
{allowedCountries, "ac"},
|
||||||
{allowedCountries, "uk"},
|
{allowedCountries, "uk"},
|
||||||
{allowedCountries, "zz"},
|
{allowedCountries, "zz"},
|
||||||
{allowedCountries, "de,germany"},
|
{allowedCountries, "de,germany"},
|
||||||
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
|
{asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
|
||||||
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
|
{asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
|
||||||
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
|
{asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
|
||||||
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
|
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
|
||||||
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"},
|
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
|
||||||
|
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
|
||||||
|
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"}, {tagDeny, "torexti"},
|
||||||
|
{tagDeny, zenTag}, {tagDeny, "abuseipdb"}, {tagDeny, "crowdsec"},
|
||||||
|
{tagLimitPercent, "torexti:50"}, {tagLimitPercent, "dnsbl=zen.example:50"},
|
||||||
|
{tagLimitPercent, "abuseipdb:50"}, {tagLimitPercent, "crowdsec:50"},
|
||||||
|
{tagLimitPercent, "vpn :50"}, {tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"},
|
||||||
|
{tagLimitPercent, "vpn:"}, {tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
|
||||||
|
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
|
||||||
|
{tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"},
|
||||||
|
{tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"},
|
||||||
|
{tagLists, "a,b=https://example.com/a.txt"},
|
||||||
|
{tagLists, "a=ftp://example.com/a.txt"},
|
||||||
|
{tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"},
|
||||||
|
{tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"},
|
||||||
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
||||||
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
||||||
{unknownLimitPercent, "50%"},
|
{unknownLimitPercent, "50%"},
|
||||||
@@ -2030,6 +2268,25 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
||||||
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
|
||||||
|
// files off, and either setting turning off the Core Rule Set, the tag
|
||||||
|
// exploitsignature in SWW_TAG_DENY stops the start.
|
||||||
|
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, name := range []string{wafMode, wafAnomalyThreshold} {
|
||||||
|
_, err := config.FromEnvironment(environment{
|
||||||
|
tagDeny: exploitTag, rulesEnabled: disabled, name: off,
|
||||||
|
}.lookupEnv)
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
|
||||||
|
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
|
||||||
|
name, err, tagDeny)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
||||||
@@ -2042,7 +2299,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
|||||||
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
||||||
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
||||||
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
||||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
|
||||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||||
})
|
})
|
||||||
@@ -2169,7 +2426,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
|||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
|
|
||||||
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||||
`characters without a space, such as gitea`
|
`characters without a space, such as blog`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -2284,11 +2541,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
lookupSource: defaultLookupSource,
|
lookupSource: defaultLookupSource,
|
||||||
lookupDBPath: "",
|
lookupDBPath: "",
|
||||||
lookupTimeout: "800ms",
|
lookupTimeout: "800ms",
|
||||||
addLookupHeaders: "false",
|
addLookupHeaders: disabled,
|
||||||
deniedCountries: "",
|
|
||||||
allowedCountries: "",
|
allowedCountries: "",
|
||||||
asnLimitPercent: "",
|
tagLists: "",
|
||||||
countryLimitPercent: "",
|
tagDeny: "",
|
||||||
|
tagLimitPercent: "",
|
||||||
asnBytesPercent: "",
|
asnBytesPercent: "",
|
||||||
countryBytesPercent: "",
|
countryBytesPercent: "",
|
||||||
unknownLimitPercent: "100",
|
unknownLimitPercent: "100",
|
||||||
@@ -2304,6 +2561,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
reputationAction: "limit:25",
|
reputationAction: "limit:25",
|
||||||
reputationCacheTTL: defaultReputationCacheTTL,
|
reputationCacheTTL: defaultReputationCacheTTL,
|
||||||
reputationTimeout: "2s",
|
reputationTimeout: "2s",
|
||||||
|
routeWatchURL: "",
|
||||||
crowdSecURL: "",
|
crowdSecURL: "",
|
||||||
crowdSecKey: "",
|
crowdSecKey: "",
|
||||||
banResponse: "403",
|
banResponse: "403",
|
||||||
@@ -2313,7 +2571,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
attackBanDuration: "7d",
|
attackBanDuration: "7d",
|
||||||
maxBans: "5000",
|
maxBans: "5000",
|
||||||
banScopeV4Prefix: "32",
|
banScopeV4Prefix: "32",
|
||||||
stateDir: "/var/lib/smallwebwaf",
|
stateDir: "/var/lib/sww",
|
||||||
stateWriteDelay: "10s",
|
stateWriteDelay: "10s",
|
||||||
stateCounterInterval: "15m",
|
stateCounterInterval: "15m",
|
||||||
adminToken: "",
|
adminToken: "",
|
||||||
@@ -2322,7 +2580,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
instanceName: hostname,
|
instanceName: hostname,
|
||||||
logRequestHeaders: defaultLogRequestHeaders,
|
logRequestHeaders: defaultLogRequestHeaders,
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
rulesDir: "/etc/sww/rules.d",
|
||||||
rulesEnabled: "true",
|
rulesEnabled: "true",
|
||||||
wafMode: config.WAFModeBlock,
|
wafMode: config.WAFModeBlock,
|
||||||
wafParanoiaLevel: "1",
|
wafParanoiaLevel: "1",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero
|
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
|
||||||
// is off.
|
// is off.
|
||||||
type RProxy struct {
|
type RProxy struct {
|
||||||
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
||||||
@@ -22,12 +22,12 @@ type RProxy struct {
|
|||||||
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
||||||
// (SWRP_TRUSTED_PROXIES).
|
// (SWRP_TRUSTED_PROXIES).
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
// WAFBackend is servewaf's URL (SWRP_WAF_BACKEND).
|
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
|
||||||
WAFBackend *url.URL
|
WAFBackend *url.URL
|
||||||
// Timeout is how long rproxy waits for servewaf's answer to a question,
|
// Timeout is how long rproxy waits for discriminator's answer to a question,
|
||||||
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// DefaultDisposition is what is done with a request when servewaf cannot be
|
// DefaultDisposition is what is done with a request when discriminator cannot be
|
||||||
// connected to, does not answer at all, or does not answer within Timeout:
|
// connected to, does not answer at all, or does not answer within Timeout:
|
||||||
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||||
DefaultDisposition string
|
DefaultDisposition string
|
||||||
@@ -50,7 +50,7 @@ type RProxy struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
errNeedsWAFBackend = errors.New(
|
errNeedsWAFBackend = errors.New(
|
||||||
"is required: it is servewaf's URL, such as http://servewaf:8080")
|
"is required: it is discriminator's URL, such as http://discriminator:8080")
|
||||||
errNotDisposition = errors.New("is not allow or deny")
|
errNotDisposition = errors.New("is not allow or deny")
|
||||||
errNotMilliseconds = errors.New(
|
errNotMilliseconds = errors.New(
|
||||||
"is not a whole number of milliseconds above zero, such as 2000")
|
"is not a whole number of milliseconds above zero, such as 2000")
|
||||||
@@ -124,7 +124,7 @@ func (c *RProxy) LogValue() slog.Value {
|
|||||||
return slog.GroupValue(c.settings...)
|
return slog.GroupValue(c.settings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wafBackend reads the setting that is servewaf's URL, which must be set.
|
// wafBackend reads the setting that is discriminator's URL, which must be set.
|
||||||
func (e *environment) wafBackend(name string) *url.URL {
|
func (e *environment) wafBackend(name string) *url.URL {
|
||||||
value := e.value(name, "")
|
value := e.value(name, "")
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL {
|
|||||||
return backend
|
return backend
|
||||||
}
|
}
|
||||||
|
|
||||||
// disposition reads the setting that is what is done with a request when servewaf
|
// disposition reads the setting that is what is done with a request when discriminator
|
||||||
// cannot be connected to, does not answer at all, or does not answer in time:
|
// cannot be connected to, does not answer at all, or does not answer in time:
|
||||||
// allow or deny.
|
// allow or deny.
|
||||||
func (e *environment) disposition(name, defaultValue string) string {
|
func (e *environment) disposition(name, defaultValue string) string {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const (
|
|||||||
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||||
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||||
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||||
backendURL = "http://servewaf:8080"
|
backendURL = "http://discriminator:8080"
|
||||||
rproxyDefaultAddr = ":8080"
|
rproxyDefaultAddr = ":8080"
|
||||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||||
notASize = "a lot"
|
notASize = "a lot"
|
||||||
@@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
|||||||
name, value string
|
name, value string
|
||||||
}{
|
}{
|
||||||
{wafBackend, ""},
|
{wafBackend, ""},
|
||||||
{wafBackend, "servewaf:8080"},
|
{wafBackend, "discriminator:8080"},
|
||||||
{wafBackend, "http://servewaf:8080/path"},
|
{wafBackend, "http://discriminator:8080/path"},
|
||||||
{rproxyTimeout, "0"},
|
{rproxyTimeout, "0"},
|
||||||
{rproxyTimeout, "-1"},
|
{rproxyTimeout, "-1"},
|
||||||
{rproxyTimeout, off},
|
{rproxyTimeout, off},
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||||
// method, the URL with its query and the headers of a request, and on its
|
// method, the URL with its query and the headers of a request, and on its
|
||||||
// body while SWWAF_WAF_BODY_LIMIT is set, with the eight changes smallwebwaf
|
// body while SWW_WAF_BODY_LIMIT is set, with the nine changes sww
|
||||||
// makes to it and the exclusions in SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
||||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
||||||
// response.
|
// response.
|
||||||
//
|
//
|
||||||
// smallwebwaf writes only to its state directory, so Coraza is built with
|
// sww writes only to its state directory, so Coraza is built with
|
||||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||||
// file in a multipart body, Coraza then counts the bytes instead of
|
// file in a multipart body, Coraza then counts the bytes instead of
|
||||||
// writing them to the system's temporary directory.
|
// writing them to the system's temporary directory.
|
||||||
package waf
|
package coreruleset
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
@@ -30,18 +30,18 @@ import (
|
|||||||
"github.com/corazawaf/coraza/v3/types"
|
"github.com/corazawaf/coraza/v3/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// directives are the Core Rule Set as smallwebwaf runs it, with the
|
// directives are the Core Rule Set as sww runs it, with the
|
||||||
// paranoia level for %d, bodyDirectives for the first %s while
|
// paranoia level for %d, bodyDirectives for the first %s while
|
||||||
// SWWAF_WAF_BODY_LIMIT is set, the exclusions file for the second, and
|
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
||||||
// giteaExclusions for the third.
|
// giteaExclusions for the third, and wordpressExclusions for the fourth.
|
||||||
// Each rule smallwebwaf adds has an id from 900000 to 900999, the ids the
|
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||||
// Core Rule Set keeps for the rules that set it up, which
|
// Core Rule Set keeps for the rules that set it up, which
|
||||||
// SWWAF_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||||
// setting switches one off. Coraza joins a line ending in \ to the next,
|
// setting switches one off. Coraza joins a line ending in \ to the next,
|
||||||
// without the spaces at the start of the next.
|
// without the spaces at the start of the next.
|
||||||
const directives = `
|
const directives = `
|
||||||
# The engine only detects. smallwebwaf compares the request's anomaly
|
# The engine only detects. sww compares the request's anomaly
|
||||||
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
|
# score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
|
||||||
# alike. It reads no body, unless bodyDirectives switch that on.
|
# alike. It reads no body, unless bodyDirectives switch that on.
|
||||||
SecRuleEngine DetectionOnly
|
SecRuleEngine DetectionOnly
|
||||||
SecRequestBodyAccess Off
|
SecRequestBodyAccess Off
|
||||||
@@ -84,6 +84,9 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
|||||||
# The eighth change comes before the rules for the same reason.
|
# The eighth change comes before the rules for the same reason.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
|
# The ninth change comes before the rules for the same reason.
|
||||||
|
%s
|
||||||
|
|
||||||
# The sixth: only the rules for requests are loaded, and no response is
|
# The sixth: only the rules for requests are loaded, and no response is
|
||||||
# inspected.
|
# inspected.
|
||||||
Include @owasp_crs/REQUEST-*.conf
|
Include @owasp_crs/REQUEST-*.conf
|
||||||
@@ -175,6 +178,13 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
|||||||
//go:embed gitea.conf
|
//go:embed gitea.conf
|
||||||
var giteaExclusions string
|
var giteaExclusions string
|
||||||
|
|
||||||
|
// wordpressExclusions are the ninth change: the fields of WordPress's own
|
||||||
|
// requests that skip the rules that refused them there, as Coraza
|
||||||
|
// directives.
|
||||||
|
//
|
||||||
|
//go:embed wordpress.conf
|
||||||
|
var wordpressExclusions string
|
||||||
|
|
||||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
||||||
// without, the rest of the fifth change.
|
// without, the rest of the fifth change.
|
||||||
//
|
//
|
||||||
@@ -183,21 +193,21 @@ var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// ParanoiaLevel is SWWAF_WAF_PARANOIA_LEVEL, from 1 to 4.
|
// ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4.
|
||||||
ParanoiaLevel int
|
ParanoiaLevel int
|
||||||
// DisabledRules are the ids of the rules switched off
|
// DisabledRules are the ids of the rules switched off
|
||||||
// (SWWAF_WAF_DISABLED_RULES).
|
// (SWW_WAF_DISABLED_RULES).
|
||||||
DisabledRules []int
|
DisabledRules []int
|
||||||
// BodyLimit is the most of a request body the Core Rule Set reads
|
// BodyLimit is the most of a request body the Core Rule Set reads
|
||||||
// (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
// (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
||||||
BodyLimit int64
|
BodyLimit int64
|
||||||
// ExclusionsFile is the path of a file of exclusions loaded before the
|
// ExclusionsFile is the path of a file of exclusions loaded before the
|
||||||
// Core Rule Set's rules (SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||||
// there is none.
|
// there is none.
|
||||||
ExclusionsFile string
|
ExclusionsFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ids of smallwebwaf's own rules, which an exclusions file may not
|
// The ids of sww's own rules, which an exclusions file may not
|
||||||
// name.
|
// name.
|
||||||
const (
|
const (
|
||||||
firstOwnRuleID = 900000
|
firstOwnRuleID = 900000
|
||||||
@@ -205,23 +215,56 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// numberOrRange matches a number, or a range of numbers such as
|
// numberOrRange matches a number, or a range of numbers such as
|
||||||
// 900000-900999.
|
// 900000-900999. Coraza reads a number with a + before it too, so the
|
||||||
var numberOrRange = regexp.MustCompile(`(\d+)(?:-(\d+))?`)
|
// end of a range may have one.
|
||||||
|
var numberOrRange = regexp.MustCompile(`(\d+)(?:-\+?(\d+))?`)
|
||||||
|
|
||||||
// errSmallwebwafRuleID is the error of an exclusions file that names an
|
// errSwwRuleID is the error of an exclusions file that names an
|
||||||
// id of smallwebwaf's own rules.
|
// id of sww's own rules.
|
||||||
var errSmallwebwafRuleID = errors.New("an id of smallwebwaf's own rules")
|
var errSwwRuleID = errors.New("an id of sww's own rules")
|
||||||
|
|
||||||
// ownRuleNamed returns the first number or range in exclusions, the text
|
// errChangesNoRule is the error of an exclusions file that holds a
|
||||||
// of an exclusions file, that takes in an id of smallwebwaf's own rules,
|
// directive that removes or changes rules as it loads, such as
|
||||||
// or "" if none does. Comment lines are skipped, as Coraza skips them.
|
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so
|
||||||
func ownRuleNamed(exclusions string) string {
|
// the directive finds none of them.
|
||||||
for line := range strings.Lines(exclusions) {
|
var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
|
||||||
if strings.HasPrefix(strings.TrimSpace(line), "#") {
|
"switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " +
|
||||||
|
"with a ctl exclusion")
|
||||||
|
|
||||||
|
// directivesIn returns the directives in text as Coraza reads them: it
|
||||||
|
// skips blank lines and comment lines, and joins a line ending in \ to
|
||||||
|
// the next, without the spaces around either.
|
||||||
|
func directivesIn(text string) []string {
|
||||||
|
var directives []string
|
||||||
|
|
||||||
|
joined := ""
|
||||||
|
|
||||||
|
for line := range strings.Lines(text) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, match := range numberOrRange.FindAllStringSubmatch(line, -1) {
|
if start, found := strings.CutSuffix(line, `\`); found {
|
||||||
|
joined += start
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
directives = append(directives, joined+line)
|
||||||
|
joined = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return directives
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkExclusions returns an error if exclusions, the text of an
|
||||||
|
// exclusions file, names an id of sww's own rules, alone or in a
|
||||||
|
// range, or holds a directive whose name starts with SecRuleRemove or
|
||||||
|
// SecRuleUpdate.
|
||||||
|
func checkExclusions(exclusions string) error {
|
||||||
|
for _, directive := range directivesIn(exclusions) {
|
||||||
|
for _, match := range numberOrRange.FindAllStringSubmatch(directive, -1) {
|
||||||
first, _ := strconv.Atoi(match[1])
|
first, _ := strconv.Atoi(match[1])
|
||||||
|
|
||||||
last := first
|
last := first
|
||||||
@@ -230,27 +273,36 @@ func ownRuleNamed(exclusions string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if first <= lastOwnRuleID && last >= firstOwnRuleID {
|
if first <= lastOwnRuleID && last >= firstOwnRuleID {
|
||||||
return match[0]
|
return fmt.Errorf("the exclusions file names %s: %w", match[0],
|
||||||
|
errSwwRuleID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coraza takes the directive's name up to the first space, without
|
||||||
|
// regard to case.
|
||||||
|
name, _, _ := strings.Cut(directive, " ")
|
||||||
|
if lower := strings.ToLower(name); strings.HasPrefix(lower, "secruleremove") ||
|
||||||
|
strings.HasPrefix(lower, "secruleupdate") {
|
||||||
|
return fmt.Errorf("%s in the exclusions file changes no rule: %w", name,
|
||||||
|
errChangesNoRule)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
||||||
// for concurrent use.
|
// for concurrent use.
|
||||||
type CoreRuleSet struct {
|
type CoreRuleSet struct {
|
||||||
waf coraza.WAF
|
engine coraza.WAF
|
||||||
bodyLimit int64
|
bodyLimit int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the Core Rule Set with the eight changes, at params'
|
// New returns the Core Rule Set with the nine changes, at params'
|
||||||
// paranoia level, without the rules it switches off, reading request
|
// paranoia level, without the rules it switches off, reading request
|
||||||
// bodies up to params' limit, and with params' exclusions file loaded
|
// bodies up to params' limit, and with params' exclusions file loaded
|
||||||
// before the Core Rule Set's rules. An exclusions file that cannot be
|
// before the Core Rule Set's rules. An exclusions file is an error if it
|
||||||
// read, does not parse or names an id of smallwebwaf's own rules is an
|
// cannot be read or does not parse, or if checkExclusions refuses it.
|
||||||
// error.
|
|
||||||
func New(params Params) (*CoreRuleSet, error) {
|
func New(params Params) (*CoreRuleSet, error) {
|
||||||
body := ""
|
body := ""
|
||||||
if params.BodyLimit > 0 {
|
if params.BodyLimit > 0 {
|
||||||
@@ -267,14 +319,14 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
|
|
||||||
exclusions = string(file)
|
exclusions = string(file)
|
||||||
|
|
||||||
if named := ownRuleNamed(exclusions); named != "" {
|
err = checkExclusions(exclusions)
|
||||||
return nil, fmt.Errorf("the exclusions file names %s: %w", named,
|
if err != nil {
|
||||||
errSmallwebwafRuleID)
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||||
giteaExclusions)
|
giteaExclusions, wordpressExclusions)
|
||||||
|
|
||||||
if len(params.DisabledRules) > 0 {
|
if len(params.DisabledRules) > 0 {
|
||||||
ids := make([]string, len(params.DisabledRules))
|
ids := make([]string, len(params.DisabledRules))
|
||||||
@@ -285,14 +337,14 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n"
|
text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
waf, err := coraza.NewWAF(coraza.NewWAFConfig().
|
engine, err := coraza.NewWAF(coraza.NewWAFConfig().
|
||||||
WithRootFS(coreruleset.FS).
|
WithRootFS(coreruleset.FS).
|
||||||
WithDirectives(text))
|
WithDirectives(text))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil
|
return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is what the Core Rule Set found in a request.
|
// Result is what the Core Rule Set found in a request.
|
||||||
@@ -312,7 +364,7 @@ type Result struct {
|
|||||||
func (c *CoreRuleSet) Inspect(
|
func (c *CoreRuleSet) Inspect(
|
||||||
r *http.Request, client netip.Addr, body io.Reader,
|
r *http.Request, client netip.Addr, body io.Reader,
|
||||||
) (Result, []byte, error) {
|
) (Result, []byte, error) {
|
||||||
tx := c.waf.NewTransaction()
|
tx := c.engine.NewTransaction()
|
||||||
// Closing would remove the files Coraza wrote, and it writes none.
|
// Closing would remove the files Coraza wrote, and it writes none.
|
||||||
defer func() { _ = tx.Close() }()
|
defer func() { _ = tx.Close() }()
|
||||||
|
|
||||||
@@ -362,7 +414,7 @@ func (c *CoreRuleSet) Inspect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readBody reads body, the body of the request in tx, which has run on
|
// readBody reads body, the body of the request in tx, which has run on
|
||||||
// the request's headers, while SWWAF_WAF_BODY_LIMIT is set and the body is
|
// the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is
|
||||||
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
||||||
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
||||||
// when they are no longer than that, since they cannot be read in part.
|
// when they are no longer than that, since they cannot be read in part.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package waf_test
|
package coreruleset_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -12,10 +13,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultDisabledRules are the rules SWWAF_WAF_DISABLED_RULES switches off
|
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
|
||||||
// by default.
|
// by default.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
@@ -23,10 +24,12 @@ var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140}
|
|||||||
|
|
||||||
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with
|
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with
|
||||||
// the rules in disabled switched off.
|
// the rules in disabled switched off.
|
||||||
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *waf.CoreRuleSet {
|
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *coreruleset.CoreRuleSet {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
crs, err := waf.New(waf.Params{ParanoiaLevel: level, DisabledRules: disabled})
|
crs, err := coreruleset.New(coreruleset.Params{
|
||||||
|
ParanoiaLevel: level, DisabledRules: disabled,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load the Core Rule Set: %v", err)
|
t.Fatalf("load the Core Rule Set: %v", err)
|
||||||
}
|
}
|
||||||
@@ -49,7 +52,7 @@ func get(target string, headers ...string) request {
|
|||||||
|
|
||||||
// inspect returns what crs finds in r, sent to git.example by a browser,
|
// inspect returns what crs finds in r, sent to git.example by a browser,
|
||||||
// whose Host, User-Agent and Accept r.headers may replace.
|
// whose Host, User-Agent and Accept r.headers may replace.
|
||||||
func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result {
|
func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
result, _ := inspectBody(t, crs, r, "")
|
result, _ := inspectBody(t, crs, r, "")
|
||||||
@@ -60,8 +63,8 @@ func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result {
|
|||||||
// inspectBody is inspect for r with body, which is announced with its
|
// inspectBody is inspect for r with body, which is announced with its
|
||||||
// Content-Length unless it is "", and returns what crs read of body too.
|
// Content-Length unless it is "", and returns what crs read of body too.
|
||||||
func inspectBody(
|
func inspectBody(
|
||||||
t *testing.T, crs *waf.CoreRuleSet, r request, body string,
|
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
|
||||||
) (waf.Result, string) {
|
) (coreruleset.Result, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), r.method,
|
req := httptest.NewRequestWithContext(t.Context(), r.method,
|
||||||
@@ -96,7 +99,9 @@ func inspectBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantResult checks what crs finds in r.
|
// wantResult checks what crs finds in r.
|
||||||
func wantResult(t *testing.T, crs *waf.CoreRuleSet, r request, want waf.Result) {
|
func wantResult(
|
||||||
|
t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
|
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
|
||||||
@@ -106,28 +111,29 @@ func wantResult(t *testing.T, crs *waf.CoreRuleSet, r request, want waf.Result)
|
|||||||
|
|
||||||
// matched is the result of a request that the rules ids match, each of
|
// matched is the result of a request that the rules ids match, each of
|
||||||
// them a critical one, which adds 5 to the score.
|
// them a critical one, which adds 5 to the score.
|
||||||
func matched(ids ...int) waf.Result {
|
func matched(ids ...int) coreruleset.Result {
|
||||||
const critical = 5
|
const critical = 5
|
||||||
|
|
||||||
return waf.Result{RuleIDs: ids, Score: critical * len(ids)}
|
return coreruleset.Result{RuleIDs: ids, Score: critical * len(ids)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// atDefaults returns the Core Rule Set as smallwebwaf runs it by default.
|
// atDefaults returns the Core Rule Set as sww runs it by default.
|
||||||
func atDefaults(t *testing.T) *waf.CoreRuleSet {
|
func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return newCoreRuleSet(t, 1, defaultDisabledRules...)
|
return newCoreRuleSet(t, 1, defaultDisabledRules...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantChange checks that crs lets through passes, a gitea request one of
|
// wantChange checks that crs lets through passes, a request one of the
|
||||||
// the eight changes is for, and still finds result in refused, a request
|
// nine changes is for, and still finds result in refused, a request like
|
||||||
// like it that the change is not for.
|
// it that the change is not for.
|
||||||
func wantChange(
|
func wantChange(
|
||||||
t *testing.T, crs *waf.CoreRuleSet, passes, refused request, result waf.Result,
|
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
||||||
|
result coreruleset.Result,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
wantResult(t, crs, passes, waf.Result{})
|
wantResult(t, crs, passes, coreruleset.Result{})
|
||||||
wantResult(t, crs, refused, result)
|
wantResult(t, crs, refused, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,12 +168,12 @@ func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
|
|||||||
|
|
||||||
wantResult(t, crs,
|
wantResult(t, crs,
|
||||||
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
|
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
|
||||||
waf.Result{})
|
coreruleset.Result{})
|
||||||
wantResult(t, crs,
|
wantResult(t, crs,
|
||||||
request{http.MethodPost, fetch, []string{
|
request{http.MethodPost, fetch, []string{
|
||||||
fetchType, length, "Content-Encoding: gzip",
|
fetchType, length, "Content-Encoding: gzip",
|
||||||
}},
|
}},
|
||||||
waf.Result{})
|
coreruleset.Result{})
|
||||||
|
|
||||||
// Every other header on the Core Rule Set's list stays refused.
|
// Every other header on the Core Rule Set's list stays refused.
|
||||||
for _, header := range []string{
|
for _, header := range []string{
|
||||||
@@ -196,7 +202,7 @@ func TestTransferEncodingIsRead(t *testing.T) {
|
|||||||
"Content-Type: application/x-git-receive-pack-request",
|
"Content-Type: application/x-git-receive-pack-request",
|
||||||
"Transfer-Encoding: chunked",
|
"Transfer-Encoding: chunked",
|
||||||
}},
|
}},
|
||||||
waf.Result{})
|
coreruleset.Result{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
|
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
|
||||||
@@ -250,7 +256,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
|
|||||||
for _, value := range []struct {
|
for _, value := range []struct {
|
||||||
name string
|
name string
|
||||||
// result is what a parameter that is not one of gitea's gets.
|
// result is what a parameter that is not one of gitea's gets.
|
||||||
result waf.Result
|
result coreruleset.Result
|
||||||
}{
|
}{
|
||||||
// A file on the list of system files.
|
// A file on the list of system files.
|
||||||
{".gitignore", matched(930120)},
|
{".gitignore", matched(930120)},
|
||||||
@@ -271,7 +277,7 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
|
|||||||
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
|
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
|
||||||
matched(930120, 932160))
|
matched(930120, 932160))
|
||||||
wantResult(t, crs, get("/?path=../../etc/passwd"),
|
wantResult(t, crs, get("/?path=../../etc/passwd"),
|
||||||
waf.Result{RuleIDs: []int{930100, 930110}, Score: 20})
|
coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20})
|
||||||
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
|
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +347,7 @@ func TestEmptyHeaderIsRead(t *testing.T) {
|
|||||||
|
|
||||||
// An empty User-Agent is a notice, which adds 2.
|
// An empty User-Agent is a notice, which adds 2.
|
||||||
wantResult(t, atDefaults(t), get("/", "User-Agent: "),
|
wantResult(t, atDefaults(t), get("/", "User-Agent: "),
|
||||||
waf.Result{RuleIDs: []int{920330}, Score: 2})
|
coreruleset.Result{RuleIDs: []int{920330}, Score: 2})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParanoiaLevel(t *testing.T) {
|
func TestParanoiaLevel(t *testing.T) {
|
||||||
@@ -350,7 +356,7 @@ func TestParanoiaLevel(t *testing.T) {
|
|||||||
// Accept-Charset is refused from paranoia level 2.
|
// Accept-Charset is refused from paranoia level 2.
|
||||||
r := get("/", "Accept-Charset: utf-8")
|
r := get("/", "Accept-Charset: utf-8")
|
||||||
|
|
||||||
wantResult(t, newCoreRuleSet(t, 1), r, waf.Result{})
|
wantResult(t, newCoreRuleSet(t, 1), r, coreruleset.Result{})
|
||||||
wantResult(t, newCoreRuleSet(t, 2), r, matched(920451))
|
wantResult(t, newCoreRuleSet(t, 2), r, matched(920451))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,8 +368,8 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
|
|||||||
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
|
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
|
||||||
|
|
||||||
wantResult(t, newCoreRuleSet(t, 1), r,
|
wantResult(t, newCoreRuleSet(t, 1), r,
|
||||||
waf.Result{RuleIDs: []int{911100, 920350}, Score: 8})
|
coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8})
|
||||||
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
|
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.Result{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
||||||
@@ -383,8 +389,8 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
|||||||
|
|
||||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||||
// exclusions file at path.
|
// exclusions file at path.
|
||||||
func withExclusions(path string) (*waf.CoreRuleSet, error) {
|
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
||||||
return waf.New(waf.Params{
|
return coreruleset.New(coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -422,7 +428,7 @@ func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
|
|||||||
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
|
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
crs, err := waf.New(waf.Params{
|
crs, err := coreruleset.New(coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: []int{920500},
|
ParanoiaLevel: 1, DisabledRules: []int{920500},
|
||||||
ExclusionsFile: exclusionsFile(t, backupFileExclusion),
|
ExclusionsFile: exclusionsFile(t, backupFileExclusion),
|
||||||
})
|
})
|
||||||
@@ -430,7 +436,7 @@ func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
|
|||||||
t.Fatalf("load the exclusions: %v", err)
|
t.Fatalf("load the exclusions: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantResult(t, crs, get("/notes.txt~"), waf.Result{})
|
wantResult(t, crs, get("/notes.txt~"), coreruleset.Result{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
|
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
|
||||||
@@ -446,23 +452,46 @@ func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
|
|||||||
func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
|
func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, path := range []string{
|
missing := filepath.Join(t.TempDir(), "missing.conf")
|
||||||
filepath.Join(t.TempDir(), "missing.conf"),
|
|
||||||
exclusionsFile(t, "SecNoSuchDirective On\n"),
|
_, err := withExclusions(missing)
|
||||||
// The Core Rule Set's rules are not loaded yet.
|
if err == nil {
|
||||||
exclusionsFile(t, "SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n"),
|
t.Errorf("%s loaded", missing)
|
||||||
// smallwebwaf's own rules may not be switched off or changed, by
|
}
|
||||||
// id or in a range.
|
|
||||||
exclusionsFile(t, "SecRuleRemoveById 900300\n"),
|
for _, tc := range []struct{ text, want string }{
|
||||||
exclusionsFile(t, "SecRuleRemoveById 899999-901000\n"),
|
{"SecNoSuchDirective On\n", "unknown directive"},
|
||||||
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
|
// sww's own rules may not be switched off or changed, by
|
||||||
"ctl:ruleRemoveById=899999-901000\"\n"),
|
// id or in a range, on one line or split over several.
|
||||||
exclusionsFile(t, "SecAction \"id:1,phase:1,pass,nolog,"+
|
{"SecRuleRemoveById 899999-901000\n", "names 899999-901000"},
|
||||||
"ctl:ruleRemoveTargetById=900500;ARGS:from\"\n"),
|
{
|
||||||
|
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-901000\"\n",
|
||||||
|
"names 899999-901000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-+901000\"\n",
|
||||||
|
"names 899999-+901000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=900500;ARGS:from\"\n",
|
||||||
|
"names 900500",
|
||||||
|
},
|
||||||
|
{"SecRuleRemoveById 899000-\\\n 901000\n", "names 899000-901000"},
|
||||||
|
{
|
||||||
|
"SecAction \"id:1,phase:1,pass,nolog,\\\n" +
|
||||||
|
" ctl:ruleRemoveById=9005\\\n 00\"\n",
|
||||||
|
"names 900500",
|
||||||
|
},
|
||||||
|
// The Core Rule Set's rules load after the file.
|
||||||
|
{"SecRuleRemoveById 942100\n", "SecRuleRemoveById in the exclusions file"},
|
||||||
|
{
|
||||||
|
"SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n",
|
||||||
|
"SecRuleUpdateTargetById in the exclusions file",
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
_, err := withExclusions(path)
|
_, err := withExclusions(exclusionsFile(t, tc.text))
|
||||||
if err == nil {
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
t.Errorf("%s loaded", path)
|
t.Errorf("%q gave the error %v, want one with %q", tc.text, err, tc.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,7 +531,7 @@ func giteaRequests(t *testing.T) []request {
|
|||||||
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWWAF_WAF_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
@@ -527,7 +556,383 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies.
|
// wpLogin is WordPress's sign-in path.
|
||||||
|
const wpLogin = "/wp-login.php"
|
||||||
|
|
||||||
|
// recordedRequest is a line of a file of recorded requests in testdata.
|
||||||
|
type recordedRequest struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Headers []string `json:"headers"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordpressRequests returns the recorded ordinary WordPress requests in
|
||||||
|
// testdata, each with its body.
|
||||||
|
func wordpressRequests(t *testing.T) []recordedRequest {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
text, err := os.ReadFile(filepath.Join("testdata", "wordpress-requests.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var requests []recordedRequest
|
||||||
|
|
||||||
|
for line := range strings.Lines(string(text)) {
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var r recordedRequest
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(line), &r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", line, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requests = append(requests, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
|
||||||
|
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
|
||||||
|
// but reading bodies up to the largest SWW_WAF_BODY_LIMIT.
|
||||||
|
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
crs, err := coreruleset.New(coreruleset.Params{
|
||||||
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load the Core Rule Set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return crs
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordpressAttackTargets returns attack in a query parameter no exclusion
|
||||||
|
// names, and in each field whose searches and names skip some rules there.
|
||||||
|
func wordpressAttackTargets(attack string) []string {
|
||||||
|
return []string{
|
||||||
|
"/?id=" + attack,
|
||||||
|
wpLogin + "?id=" + attack,
|
||||||
|
"/wp-admin/admin-ajax.php?id=" + attack,
|
||||||
|
"/wp-json/wp/v2/posts?id=" + attack,
|
||||||
|
"/?s=" + attack,
|
||||||
|
"/index.php?s=" + attack,
|
||||||
|
"/page/2/?s=" + attack,
|
||||||
|
"/index.php/page/2/?s=" + attack,
|
||||||
|
"/wp-admin/edit.php?s=" + attack,
|
||||||
|
"/wp-admin/upload.php?s=" + attack,
|
||||||
|
"/wp-admin/plugin-install.php?s=" + attack,
|
||||||
|
"/wp-admin/edit-comments.php?s=" + attack,
|
||||||
|
"/wp-admin/users.php?s=" + attack,
|
||||||
|
"/wp-admin/plugins.php?s=" + attack,
|
||||||
|
"/wp-admin/edit-tags.php?s=" + attack,
|
||||||
|
"/wp-json/wp/v2/search?search=" + attack,
|
||||||
|
"/wp-json/wp/v2/posts?search=" + attack,
|
||||||
|
"/wp-json/wp/v2/tags?search=" + attack,
|
||||||
|
"/?rest_route=/wp/v2/search&search=" + attack,
|
||||||
|
"/wp-json/wp/v2/block-directory/search?term=" + attack,
|
||||||
|
"/wp-admin/themes.php?search=" + attack,
|
||||||
|
"/wp-admin/theme-install.php?search=" + attack,
|
||||||
|
"/wp-admin/upload.php?search=" + attack,
|
||||||
|
"/wp-admin/admin-ajax.php?q=" + attack,
|
||||||
|
"/wp-admin/admin-ajax.php?term=" + attack,
|
||||||
|
"/wp-admin/edit.php?_wp_http_referer=" + attack,
|
||||||
|
"/wp-admin/upload.php?_wp_http_referer=" + attack,
|
||||||
|
"/?tag=" + attack,
|
||||||
|
"/index.php?tag=" + attack,
|
||||||
|
"/wp-admin/edit.php?tag=" + attack,
|
||||||
|
"/wp-admin/edit.php?category_name=" + attack,
|
||||||
|
"/wp-admin/plugin-editor.php?file=" + attack,
|
||||||
|
"/wp-admin/theme-editor.php?file=" + attack,
|
||||||
|
"/wp-admin/plugin-editor.php?plugin=" + attack,
|
||||||
|
"/wp-admin/plugins.php?plugin=" + attack,
|
||||||
|
"/wp-admin/update.php?plugin=" + attack,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
|
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||||
|
|
||||||
|
for _, recorded := range wordpressRequests(t) {
|
||||||
|
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||||
|
|
||||||
|
crsSets := []*coreruleset.CoreRuleSet{defaults}
|
||||||
|
if recorded.Body != "" {
|
||||||
|
crsSets = append(crsSets, bodies)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, crs := range crsSets {
|
||||||
|
got, _ := inspectBody(t, crs, r, recorded.Body)
|
||||||
|
if got.Score >= threshold {
|
||||||
|
t.Errorf("%s %s %.200q refused: %+v", r.method, r.target, recorded.Body, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, attack := range []string{
|
||||||
|
"1'%20OR%20'1'='1",
|
||||||
|
"..%2F..%2Fwp-config.php",
|
||||||
|
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
||||||
|
} {
|
||||||
|
for _, target := range wordpressAttackTargets(attack) {
|
||||||
|
if got := inspect(t, defaults, get(target)); got.Score < threshold {
|
||||||
|
t.Errorf("%s let through: %+v", target, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a body field no exclusion names, on the paths with exclusions.
|
||||||
|
body := "id=" + attack
|
||||||
|
for _, target := range []string{
|
||||||
|
wpLogin,
|
||||||
|
"/wp-comments-post.php",
|
||||||
|
"/wp-admin/admin-ajax.php",
|
||||||
|
"/wp-admin/post.php",
|
||||||
|
"/wp-admin/options.php",
|
||||||
|
} {
|
||||||
|
got, _ := inspectBody(t, bodies,
|
||||||
|
request{http.MethodPost, target, []string{formData}}, body)
|
||||||
|
if got.Score < threshold {
|
||||||
|
t.Errorf("POST %s %q let through: %+v", target, body, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordpressBlock is the comment that starts every block of WordPress's
|
||||||
|
// HTML, which rule 941180 matches.
|
||||||
|
const wordpressBlock = "<!-- wp:paragraph -->"
|
||||||
|
|
||||||
|
// wordpressOverride is the header with which the block editor saves.
|
||||||
|
const wordpressOverride = "X-HTTP-Method-Override: PUT"
|
||||||
|
|
||||||
|
// wordpressRESTAddresses returns the four addresses of route on the REST
|
||||||
|
// API.
|
||||||
|
func wordpressRESTAddresses(route string) []string {
|
||||||
|
return []string{
|
||||||
|
"/wp-json" + route,
|
||||||
|
"/index.php/wp-json" + route,
|
||||||
|
"/index.php?rest_route=" + route,
|
||||||
|
"/?rest_route=" + route,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := readingBodiesUpTo64K(t)
|
||||||
|
content := `{"content":"` + wordpressBlock + `"}`
|
||||||
|
|
||||||
|
for _, route := range []string{
|
||||||
|
"/wp/v2/posts/1",
|
||||||
|
"/wp/v2/posts/1/autosaves",
|
||||||
|
"/wp/v2/pages/2",
|
||||||
|
"/wp/v2/templates/twentytwentyfive//index",
|
||||||
|
"/wp/v2/template-parts/twentytwentyfive//header",
|
||||||
|
} {
|
||||||
|
for _, address := range wordpressRESTAddresses(route) {
|
||||||
|
r := request{http.MethodPost, address, []string{jsonBody, wordpressOverride}}
|
||||||
|
wantBody(t, crs, r, content, coreruleset.Result{}, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The routes that save something else keep the rules.
|
||||||
|
for _, route := range []string{
|
||||||
|
"/wp/v2/comments", "/wp/v2/users/1", "/wp/v2/settings",
|
||||||
|
} {
|
||||||
|
for _, address := range wordpressRESTAddresses(route) {
|
||||||
|
r := request{http.MethodPost, address, []string{jsonBody}}
|
||||||
|
wantBody(t, crs, r, content, matched(941180), content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WordPress follows a route in the query over one in the path.
|
||||||
|
r := request{
|
||||||
|
http.MethodPost, "/wp-json/wp/v2/posts/1?rest_route=/wp/v2/comments",
|
||||||
|
[]string{jsonBody},
|
||||||
|
}
|
||||||
|
wantBody(t, crs, r, content, matched(941180), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordPressSearchesAndFileNamesAreExcludedOnlyThere(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := atDefaults(t)
|
||||||
|
|
||||||
|
wantChange(t, crs, get("/?s=python3"), get("/wp-admin/options.php?s=python3"),
|
||||||
|
matched(932260))
|
||||||
|
wantChange(t, crs, get("/?s=python3"), get("/?q=python3"), matched(932260))
|
||||||
|
wantChange(t, crs, get("/index.php?s=ls+-la"), get("/index.php?q=ls+-la"),
|
||||||
|
matched(932250))
|
||||||
|
wantChange(t, crs, get("/wp-json/wp/v2/search?search=powershell"),
|
||||||
|
get("/wp-json/example/v1/things?search=powershell"), matched(932120))
|
||||||
|
wantChange(t, crs, get("/index.php?rest_route=/wp/v2/search&search=powershell"),
|
||||||
|
get("/index.php?rest_route=/example/v1/things&search=powershell"), matched(932120))
|
||||||
|
wantChange(t, crs,
|
||||||
|
get("/wp-admin/plugin-editor.php?file=akismet%2Fviews%2Fconfig.php"),
|
||||||
|
get("/wp-admin/options.php?file=akismet%2Fviews%2Fconfig.php"), matched(930120))
|
||||||
|
wantChange(t, crs, get("/wp-admin/theme-editor.php?file=docker-compose.yml"),
|
||||||
|
get("/wp-admin/theme-editor.php?theme=docker-compose.yml"), matched(932260))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWordPressOtherFieldsAreExcludedOnlyThere checks each field and rule
|
||||||
|
// on one of its paths against the same value where no exclusion applies.
|
||||||
|
func TestWordPressOtherFieldsAreExcludedOnlyThere(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const options = "/wp-admin/options.php"
|
||||||
|
|
||||||
|
crs := atDefaults(t)
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
target string
|
||||||
|
rule int
|
||||||
|
}{
|
||||||
|
{"/page/2/?s=python3", 932260},
|
||||||
|
{"/page/2/?s=ls+-la", 932250},
|
||||||
|
{"/page/2/?s=powershell", 932120},
|
||||||
|
{"/index.php/page/2/?s=python3", 932260},
|
||||||
|
{"/?s=.htaccess", 930120},
|
||||||
|
{"/?s=wp-config.php", 930120},
|
||||||
|
{"/?s=.bashrc", 930120},
|
||||||
|
{"/page/2/?s=.htaccess", 930120},
|
||||||
|
{"/wp-admin/edit-comments.php?s=powershell", 932120},
|
||||||
|
{"/wp-admin/users.php?s=ls+-la", 932250},
|
||||||
|
{"/wp-admin/plugins.php?s=python3", 932260},
|
||||||
|
{"/wp-admin/edit-tags.php?s=python3", 932260},
|
||||||
|
{"/wp-admin/themes.php?search=powershell", 932120},
|
||||||
|
{"/wp-admin/theme-install.php?search=ls+-la", 932250},
|
||||||
|
{"/wp-admin/upload.php?search=python3", 932260},
|
||||||
|
{"/wp-admin/admin-ajax.php?q=powershell", 932120},
|
||||||
|
{"/wp-admin/admin-ajax.php?q=ls+-la", 932250},
|
||||||
|
{"/wp-admin/admin-ajax.php?q=python3", 932260},
|
||||||
|
{"/wp-admin/admin-ajax.php?term=powershell", 932120},
|
||||||
|
{"/wp-admin/admin-ajax.php?term=ls+-la", 932250},
|
||||||
|
{"/wp-admin/admin-ajax.php?term=python3", 932260},
|
||||||
|
{"/wp-admin/edit.php?_wp_http_referer=.htaccess", 930120},
|
||||||
|
{"/wp-admin/edit.php?_wp_http_referer=powershell", 932120},
|
||||||
|
{"/wp-admin/upload.php?_wp_http_referer=ls+-la", 932250},
|
||||||
|
{"/wp-admin/users.php?_wp_http_referer=python3", 932260},
|
||||||
|
{"/wp-admin/upload.php?_wp_http_referer=%2Fupload.php%3Fmode%3Dlist%26ps", 932340},
|
||||||
|
{"/?tag=python3", 932260},
|
||||||
|
{"/index.php?tag=powershell", 932120},
|
||||||
|
{"/wp-admin/edit.php?tag=ls+-la", 932250},
|
||||||
|
{"/wp-admin/edit.php?category_name=python3", 932260},
|
||||||
|
{"/wp-admin/edit.php?category_name=powershell", 932120},
|
||||||
|
{"/wp-admin/edit.php?category_name=ls+-la", 932250},
|
||||||
|
{"/wp-admin/plugin-editor.php?file=install-script.sh", 932120},
|
||||||
|
{"/wp-admin/theme-editor.php?file=document.write.js", 941180},
|
||||||
|
{"/wp-admin/plugin-editor.php?plugin=docker-helper", 932260},
|
||||||
|
{"/wp-admin/plugins.php?plugin=powershell-tools", 932120},
|
||||||
|
{"/wp-admin/update.php?plugin=docker-helper", 932260},
|
||||||
|
} {
|
||||||
|
_, query, _ := strings.Cut(tt.target, "?")
|
||||||
|
wantChange(t, crs, get(tt.target), get(options+"?"+query), matched(tt.rule))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWordPressRESTFieldsAreExcludedOnlyThere checks search, term and the
|
||||||
|
// header X-HTTP-Method-Override at the REST API's addresses.
|
||||||
|
func TestWordPressRESTFieldsAreExcludedOnlyThere(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := atDefaults(t)
|
||||||
|
|
||||||
|
// Any route of WordPress's own API, at each address.
|
||||||
|
for _, route := range []string{
|
||||||
|
"/wp/v2/tags", "/wp/v2/categories", "/wp/v2/pages", "/wp/v2/media",
|
||||||
|
"/wp/v2/users", "/wp/v2/blocks", "/wp/v2/pattern-directory/patterns",
|
||||||
|
} {
|
||||||
|
wantChange(t, crs, get("/wp-json"+route+"?search=python3"),
|
||||||
|
get("/wp-json/example/v1/things?search=python3"), matched(932260))
|
||||||
|
wantChange(t, crs, get("/index.php?rest_route="+route+"&search=python3"),
|
||||||
|
get("/index.php?rest_route=/example/v1/things&search=python3"), matched(932260))
|
||||||
|
wantChange(t, crs, get("/?rest_route="+route+"&search=.htaccess"),
|
||||||
|
get("/?rest_route=/example/v1/things&search=.htaccess"), matched(930120))
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = "/wp-json/wp/v2/block-directory/search"
|
||||||
|
|
||||||
|
wantChange(t, crs, get(directory+"?term=python3"),
|
||||||
|
get("/wp-json/wp/v2/tags?term=python3"), matched(932260))
|
||||||
|
wantChange(t, crs, get(directory+"?term=powershell"),
|
||||||
|
get("/wp-json/wp/v2/tags?term=powershell"), matched(932120))
|
||||||
|
wantChange(t, crs, get(directory+"?term=ls+-la"),
|
||||||
|
get("/wp-json/wp/v2/tags?term=ls+-la"), matched(932250))
|
||||||
|
|
||||||
|
// The header X-HTTP-Method-Override at the address on the site root,
|
||||||
|
// and off the REST API.
|
||||||
|
headers := []string{jsonBody, "Content-Length: 2", wordpressOverride}
|
||||||
|
wantChange(t, crs,
|
||||||
|
request{http.MethodPost, "/?rest_route=/wp/v2/posts/1", headers},
|
||||||
|
request{http.MethodPost, "/?p=1", headers}, matched(920450))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordPressBodyFieldsAreCheckedInTheQuery(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := atDefaults(t)
|
||||||
|
|
||||||
|
wantResult(t, crs, get(wpLogin+"?pwd=%2Fetc%2Fhosts"), matched(930120))
|
||||||
|
wantResult(t, crs, get(wpLogin+"?PWD=%2Fetc%2Fhosts"), matched(930120))
|
||||||
|
wantResult(t, crs, get("/wp-admin/admin-ajax.php?content=ls+-la"), matched(932250))
|
||||||
|
wantResult(t, crs, get("/wp-admin/admin-ajax.php?newcontent=%3Cstyle%3E"),
|
||||||
|
matched(941100, 941160))
|
||||||
|
wantResult(t, crs,
|
||||||
|
get("/wp-json/wp/v2/posts/1?json.content="+url.QueryEscape(wordpressBlock)),
|
||||||
|
matched(941180))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
save = "/wp-json/wp/v2/posts/1"
|
||||||
|
offREST = "/api/posts/1"
|
||||||
|
length = "Content-Length: 2"
|
||||||
|
)
|
||||||
|
|
||||||
|
crs := readingBodiesUpTo64K(t)
|
||||||
|
saving := request{http.MethodPost, save, []string{jsonBody, wordpressOverride}}
|
||||||
|
|
||||||
|
content := `{"content":"` + wordpressBlock + `"}`
|
||||||
|
wantBody(t, crs, saving, content, coreruleset.Result{}, content)
|
||||||
|
|
||||||
|
// The other fields of the post keep the rule, and so does the same field
|
||||||
|
// off the REST API.
|
||||||
|
title := `{"title":"` + wordpressBlock + `"}`
|
||||||
|
wantBody(t, crs, saving, title, matched(941180), title)
|
||||||
|
wantBody(t, crs, request{http.MethodPost, offREST, []string{jsonBody}}, content,
|
||||||
|
matched(941180), content)
|
||||||
|
|
||||||
|
// The header X-HTTP-Method-Override is refused off the REST API.
|
||||||
|
defaults := atDefaults(t)
|
||||||
|
wantResult(t, defaults,
|
||||||
|
request{http.MethodPost, save, []string{jsonBody, length, wordpressOverride}},
|
||||||
|
coreruleset.Result{})
|
||||||
|
wantResult(t, defaults,
|
||||||
|
request{http.MethodPost, offREST, []string{jsonBody, length, wordpressOverride}},
|
||||||
|
matched(920450))
|
||||||
|
|
||||||
|
// Signing in: pwd alone skips the list of system files.
|
||||||
|
signIn := request{http.MethodPost, wpLogin, []string{formData}}
|
||||||
|
|
||||||
|
body := "log=a&pwd=b%20%2Fetc%2Fhosts"
|
||||||
|
wantBody(t, crs, signIn, body, coreruleset.Result{}, body)
|
||||||
|
|
||||||
|
body = "log=b%20%2Fetc%2Fhosts&pwd=a"
|
||||||
|
wantBody(t, crs, signIn, body, matched(930120), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies.
|
||||||
const bodyLimit = 8 << 10
|
const bodyLimit = 8 << 10
|
||||||
|
|
||||||
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
||||||
@@ -541,12 +946,12 @@ const (
|
|||||||
// injection is an SQL injection, which rule 942100 matches.
|
// injection is an SQL injection, which rule 942100 matches.
|
||||||
const injection = "1' OR '1'='1"
|
const injection = "1' OR '1'='1"
|
||||||
|
|
||||||
// readingBodies returns the Core Rule Set as smallwebwaf runs it by
|
// readingBodies returns the Core Rule Set as sww runs it by
|
||||||
// default, but reading bodies up to bodyLimit.
|
// default, but reading bodies up to bodyLimit.
|
||||||
func readingBodies(t *testing.T) *waf.CoreRuleSet {
|
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
crs, err := waf.New(waf.Params{
|
crs, err := coreruleset.New(coreruleset.Params{
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -580,7 +985,8 @@ func padded(head, tail string, n int) string {
|
|||||||
// wantBody checks what crs finds in r with body, and that what it read of
|
// wantBody checks what crs finds in r with body, and that what it read of
|
||||||
// body is read.
|
// body is read.
|
||||||
func wantBody(
|
func wantBody(
|
||||||
t *testing.T, crs *waf.CoreRuleSet, r request, body string, want waf.Result,
|
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
|
||||||
|
want coreruleset.Result,
|
||||||
read string,
|
read string,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -604,7 +1010,7 @@ func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
|
|||||||
{jsonBody, `{"q":"` + injection + `"}`},
|
{jsonBody, `{"q":"` + injection + `"}`},
|
||||||
{xmlBody, "<q>" + injection + "</q>"},
|
{xmlBody, "<q>" + injection + "</q>"},
|
||||||
} {
|
} {
|
||||||
wantBody(t, off, post(tc.header), tc.body, waf.Result{}, "")
|
wantBody(t, off, post(tc.header), tc.body, coreruleset.Result{}, "")
|
||||||
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
|
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,7 +1033,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
||||||
tc.attackFirst[:bodyLimit+1])
|
tc.attackFirst[:bodyLimit+1])
|
||||||
wantBody(t, crs, post(tc.header), tc.attackLast, waf.Result{},
|
wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{},
|
||||||
tc.attackLast[:bodyLimit+1])
|
tc.attackLast[:bodyLimit+1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,7 +1043,7 @@ func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
|||||||
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
|
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
|
||||||
|
|
||||||
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
|
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
|
||||||
wantBody(t, crs, post(formData), pastLimit, waf.Result{}, pastLimit)
|
wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
||||||
@@ -653,7 +1059,7 @@ func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
|||||||
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
||||||
|
|
||||||
larger := padded(tc.head, tc.tail, bodyLimit+1)
|
larger := padded(tc.head, tc.tail, bodyLimit+1)
|
||||||
wantBody(t, crs, post(tc.header), larger, waf.Result{}, larger)
|
wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,7 +1077,7 @@ func TestOtherBodiesAreNotRead(t *testing.T) {
|
|||||||
"Content-Type: text/plain",
|
"Content-Type: text/plain",
|
||||||
"Content-Type: application/x-git-receive-pack-request",
|
"Content-Type: application/x-git-receive-pack-request",
|
||||||
} {
|
} {
|
||||||
wantBody(t, crs, post(header), body, waf.Result{}, "")
|
wantBody(t, crs, post(header), body, coreruleset.Result{}, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,8 +1105,8 @@ func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.
|
|||||||
// It is allowed on a body of any other kind, and on every body while no
|
// It is allowed on a body of any other kind, and on every body while no
|
||||||
// body is read.
|
// body is read.
|
||||||
fetch := "Content-Type: application/x-git-upload-pack-request"
|
fetch := "Content-Type: application/x-git-upload-pack-request"
|
||||||
wantBody(t, crs, post(fetch, gzip), "a", waf.Result{}, "")
|
wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "")
|
||||||
wantBody(t, atDefaults(t), post(formData, gzip), "a", waf.Result{}, "")
|
wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
|
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
|
||||||
@@ -711,18 +1117,18 @@ func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
body string
|
body string
|
||||||
want waf.Result
|
want coreruleset.Result
|
||||||
}{
|
}{
|
||||||
{"path=.gitignore", waf.Result{}},
|
{"path=.gitignore", coreruleset.Result{}},
|
||||||
{"q=.gitignore", matched(930120)},
|
{"q=.gitignore", matched(930120)},
|
||||||
{"redirect_uri=" + local, waf.Result{}},
|
{"redirect_uri=" + local, coreruleset.Result{}},
|
||||||
{"next=" + local, matched(931100, 934110)},
|
{"next=" + local, matched(931100, 934110)},
|
||||||
} {
|
} {
|
||||||
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
|
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := field("path", ".gitignore") + end
|
body := field("path", ".gitignore") + end
|
||||||
wantBody(t, crs, post(multipart), body, waf.Result{}, body)
|
wantBody(t, crs, post(multipart), body, coreruleset.Result{}, body)
|
||||||
|
|
||||||
body = field("q", ".gitignore") + end
|
body = field("q", ".gitignore") + end
|
||||||
wantBody(t, crs, post(multipart), body, matched(930120), body)
|
wantBody(t, crs, post(multipart), body, matched(930120), body)
|
||||||
@@ -846,7 +1252,7 @@ func TestCorazaWritesNoFile(t *testing.T) {
|
|||||||
func TestBodyLimitOf1GLoads(t *testing.T) {
|
func TestBodyLimitOf1GLoads(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := waf.New(waf.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
|
_, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
|
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
# The eighth change: on gitea's own paths, the fields in which gitea sends a
|
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
||||||
# search, a file name or a branch name skip the rules that refused ordinary
|
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
||||||
# gitea requests there. Each exclusion runs with the request, before the rules
|
# a file name or a branch name. Each exclusion runs with the request, before the
|
||||||
# it switches off, those of phase 1 included, so this file is loaded before the
|
# rules it switches off, those of phase 1 included, so this file is loaded before
|
||||||
# Core Rule Set's rules. Each comment names a request the rule refused.
|
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
||||||
|
|
||||||
# gitea's searches send the text searched for in q: searches of code, of issues
|
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||||
# and pull requests, of commits, and of repositories, branches, tags, packages
|
# text searched for in q, which skips these rules there:
|
||||||
# and users. q skips these rules there:
|
|
||||||
# 930110: ../
|
# 930110: ../
|
||||||
# 930120: .gitignore
|
# 930120: .gitignore
|
||||||
# 931100: http://127.0.0.1:8080
|
# 931100: http://127.0.0.1:8080
|
||||||
@@ -36,11 +35,11 @@
|
|||||||
# 944100: java.lang.Runtime
|
# 944100: java.lang.Runtime
|
||||||
# 944110: java.lang.Runtime
|
# 944110: java.lang.Runtime
|
||||||
# 944130: java.lang.Runtime
|
# 944130: java.lang.Runtime
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:explore/(?:repos|code)|issues(?:/search)?|pulls|\
|
SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
|
||||||
user/search_candidates|[^/]+/-/(?:code|packages)|\
|
[^/]+/-/code|\
|
||||||
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|branches|tags|\
|
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
||||||
commits/(?:branch|tag|commit)/.+/search)|\
|
commits/(?:branch|tag|commit)/.+/search)|\
|
||||||
api/v1/repos/(?:search|issues/search|[^/]+/[^/]+/issues))$" \
|
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))$" \
|
||||||
"id:900600,phase:1,pass,nolog,\
|
"id:900600,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=930110;ARGS:q,\
|
ctl:ruleRemoveTargetById=930110;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||||
@@ -79,11 +78,11 @@ SecRule REQUEST_FILENAME "@rx ^/(?:explore/(?:repos|code)|issues(?:/search)?|pul
|
|||||||
# 941160: <img src=
|
# 941160: <img src=
|
||||||
# 942100: LIKE '%foo%'
|
# 942100: LIKE '%foo%'
|
||||||
# 944100: java.lang.Runtime
|
# 944100: java.lang.Runtime
|
||||||
SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/(?:repos|code)|\
|
SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/code|\
|
||||||
issues(?:/search)?|pulls|user/search_candidates|[^/]+/-/(?:code|packages)|\
|
issues(?:/search)?|pulls|[^/]+/-/code|\
|
||||||
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|branches|tags|\
|
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
||||||
commits/(?:branch|tag|commit)/.+/search)|\
|
commits/(?:branch|tag|commit)/.+/search)|\
|
||||||
api/v1/repos/(?:search|issues/search|[^/]+/[^/]+/issues))\?" \
|
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))\?" \
|
||||||
"id:900601,phase:1,pass,nolog,\
|
"id:900601,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
|
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
|
||||||
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
|
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
|
||||||
@@ -91,15 +90,51 @@ SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/(?:repos|code)|\
|
|||||||
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
|
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
|
||||||
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
|
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
|
||||||
|
|
||||||
# A file's page, raw file and download, its blame, history, edit and delete
|
# gitea's searches of repositories, users, branches, tags and packages send a
|
||||||
# pages, and the API's call for its contents name the file in the path, which
|
# name in q, which skips these rules there:
|
||||||
# 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~
|
# 920500: notes.txt~
|
||||||
# 934100: process.env.d.ts
|
# 934100: process.env.d.ts
|
||||||
# 941130: docs/a b.xhtml
|
# 941130: docs/a b.xhtml
|
||||||
# 944130: java.lang.String.md
|
# 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 \
|
SecRule REQUEST_FILENAME \
|
||||||
"@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
|
"@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_preview|_delete|contents|releases/download)/" \
|
||||||
"id:900610,phase:1,pass,nolog,\
|
"id:900610,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME,\
|
ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME,\
|
||||||
ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME,\
|
ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME,\
|
||||||
@@ -208,6 +243,18 @@ SecRule REQUEST_FILENAME \
|
|||||||
ctl:ruleRemoveTargetById=942140;ARGS:name,\
|
ctl:ruleRemoveTargetById=942140;ARGS:name,\
|
||||||
ctl:ruleRemoveTargetById=944130;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 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
|
# the list of command names (932260) there. It refused names such as
|
||||||
# docker-build, python3-migration and feat/docker-support.
|
# docker-build, python3-migration and feat/docker-support.
|
||||||
Vendored
+43
-17
@@ -1,6 +1,6 @@
|
|||||||
# Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
|
# 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
|
# defaults. Each line is a path and query, and may end with one header after a
|
||||||
# space. The tests of internal/waf inspect each as a GET. The Core Rule Set reads
|
# 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.
|
# the query and the headers the same way for any method.
|
||||||
|
|
||||||
# Searches with ordinary words and symbols.
|
# Searches with ordinary words and symbols.
|
||||||
@@ -46,8 +46,6 @@
|
|||||||
/owner/repo/search?q=information_schema
|
/owner/repo/search?q=information_schema
|
||||||
/owner/repo/search?q=LIKE%20%27%25foo%25%27
|
/owner/repo/search?q=LIKE%20%27%25foo%25%27
|
||||||
/owner/repo/search?q=onClick%3D%7B
|
/owner/repo/search?q=onClick%3D%7B
|
||||||
/explore/repos?q=SELECT%20%2A%20FROM%20users%20WHERE
|
|
||||||
/explore/repos?q=%3Cimg%20src%3D
|
|
||||||
/explore/code?q=SELECT%20%2A%20FROM%20users%20WHERE
|
/explore/code?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
/explore/code?q=%3Cimg%20src%3D
|
/explore/code?q=%3Cimg%20src%3D
|
||||||
/owner/-/code?q=SELECT%20%2A%20FROM%20users%20WHERE
|
/owner/-/code?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
@@ -66,21 +64,33 @@
|
|||||||
/pulls?type=your_repositories&q=%3Cimg%20src%3D
|
/pulls?type=your_repositories&q=%3Cimg%20src%3D
|
||||||
/owner/repo/commits/branch/main/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
/owner/repo/commits/branch/main/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
/owner/repo/commits/branch/main/search?q=%3Cimg%20src%3D
|
/owner/repo/commits/branch/main/search?q=%3Cimg%20src%3D
|
||||||
/owner/repo/branches?q=SELECT%20%2A%20FROM%20users%20WHERE
|
|
||||||
/owner/repo/branches?q=%3Cimg%20src%3D
|
|
||||||
/owner/repo/tags?q=SELECT%20%2A%20FROM%20users%20WHERE
|
|
||||||
/owner/repo/tags?q=%3Cimg%20src%3D
|
|
||||||
/owner/-/packages?q=SELECT%20%2A%20FROM%20users%20WHERE
|
|
||||||
/owner/-/packages?q=%3Cimg%20src%3D
|
|
||||||
/user/search_candidates?q=SELECT%20%2A%20FROM%20users%20WHERE
|
|
||||||
/user/search_candidates?q=%3Cimg%20src%3D
|
|
||||||
/api/v1/repos/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
|
||||||
/api/v1/repos/search?q=%3Cimg%20src%3D
|
|
||||||
/api/v1/repos/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
/api/v1/repos/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
/api/v1/repos/issues/search?q=%3Cimg%20src%3D
|
/api/v1/repos/issues/search?q=%3Cimg%20src%3D
|
||||||
/api/v1/repos/owner/repo/issues?q=SELECT%20%2A%20FROM%20users%20WHERE
|
/api/v1/repos/owner/repo/issues?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||||
/api/v1/repos/owner/repo/issues?q=%3Cimg%20src%3D
|
/api/v1/repos/owner/repo/issues?q=%3Cimg%20src%3D
|
||||||
|
|
||||||
|
# Searches for the names of repositories, users, branches, tags and packages.
|
||||||
|
/explore/repos?q=python3-app
|
||||||
|
/explore/repos?q=.profile
|
||||||
|
/explore/repos?q=powershell-scripts
|
||||||
|
/api/v1/repos/search?q=docker-build
|
||||||
|
/user/search_candidates?q=docker-ci
|
||||||
|
/owner/repo/branches?q=feat%2Fdocker-support
|
||||||
|
/owner/repo/tags?q=docker-v1.0
|
||||||
|
/owner/-/packages?q=python3-requests
|
||||||
|
/owner/-/packages?q=org.apache.commons.logging
|
||||||
|
/repo/search?q=python3-app
|
||||||
|
/repo/search?q=docker-build
|
||||||
|
/repo/search?q=powershell-scripts
|
||||||
|
/repo/search?q=.profile
|
||||||
|
/owner?tab=repositories&q=python3-app
|
||||||
|
/org?q=python3-app
|
||||||
|
/owner/repo/issues/posters?q=docker-ci
|
||||||
|
/owner/repo/pulls/posters?q=docker-ci
|
||||||
|
/explore/users?q=docker-ci
|
||||||
|
/explore/organizations?q=docker-ci
|
||||||
|
/api/v1/users/search?q=docker-ci
|
||||||
|
|
||||||
# Pages, raw files and edit pages of files, and the API's call for their
|
# Pages, raw files and edit pages of files, and the API's call for their
|
||||||
# contents.
|
# contents.
|
||||||
/owner/repo/src/branch/main/package.json
|
/owner/repo/src/branch/main/package.json
|
||||||
@@ -160,8 +170,9 @@
|
|||||||
/owner/repo/_edit/main/notes.txt~
|
/owner/repo/_edit/main/notes.txt~
|
||||||
/api/v1/repos/owner/repo/contents/notes.txt~
|
/api/v1/repos/owner/repo/contents/notes.txt~
|
||||||
|
|
||||||
# The download, blame, history and delete pages of the files above whose names
|
# The download, blame, history, delete and preview pages of the files above
|
||||||
# the Core Rule Set checks in the path.
|
# 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/media/branch/main/notes.txt~
|
||||||
/owner/repo/blame/branch/main/notes.txt~
|
/owner/repo/blame/branch/main/notes.txt~
|
||||||
/owner/repo/commits/branch/main/notes.txt~
|
/owner/repo/commits/branch/main/notes.txt~
|
||||||
@@ -178,10 +189,17 @@
|
|||||||
/owner/repo/blame/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/commits/branch/main/java.lang.String.md
|
||||||
/owner/repo/_delete/main/java.lang.String.md
|
/owner/repo/_delete/main/java.lang.String.md
|
||||||
|
/owner/repo/_preview/main/notes.txt~
|
||||||
|
/owner/repo/_preview/main/process.env.d.ts
|
||||||
|
/owner/repo/_preview/main/java.lang.String.md
|
||||||
|
/owner/repo/releases/download/v1.0/notes.txt~
|
||||||
|
/owner/repo/releases/download/v1.0/process.env.d.ts
|
||||||
|
/owner/repo/releases/download/v1.0/java.lang.String.md
|
||||||
|
/owner/repo/releases/download/v1.0/a%20b.xhtml
|
||||||
|
|
||||||
# File names in the query string: the commit graph of a file, the API's lists
|
# File names in the query string: the commit graph of a file, the API's lists
|
||||||
# of its commits and of its contents, an issue opened from a template, and the
|
# of its commits and of its contents, an issue opened from a template, the
|
||||||
# sign-in page on the way to its edit page.
|
# sign-in page on the way to its edit page, and a diff's hidden lines.
|
||||||
/owner/repo/graph?file=package.json
|
/owner/repo/graph?file=package.json
|
||||||
/api/v1/repos/owner/repo/commits?path=package.json
|
/api/v1/repos/owner/repo/commits?path=package.json
|
||||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22package.json%22%5D%7D
|
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22package.json%22%5D%7D
|
||||||
@@ -267,6 +285,14 @@
|
|||||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22%5Bdraft%5D.md%22%5D%7D
|
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22%5Bdraft%5D.md%22%5D%7D
|
||||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F%5Bdraft%5D.md
|
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F%5Bdraft%5D.md
|
||||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F%5Bdraft%5D.md
|
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F%5Bdraft%5D.md
|
||||||
|
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=process.env.d.ts&style=unified&direction=down&wiki=false
|
||||||
|
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=java.lang.String.md&style=unified&direction=down&wiki=false
|
||||||
|
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=install-script.sh&style=unified&direction=down&wiki=false
|
||||||
|
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=information_schema.sql&style=unified&direction=down&wiki=false
|
||||||
|
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=document.write.js&style=unified&direction=down&wiki=false
|
||||||
|
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=__proto__.json&style=unified&direction=down&wiki=false
|
||||||
|
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java&style=unified&direction=down&wiki=false
|
||||||
|
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=index.xhtml&style=unified&direction=down&wiki=false
|
||||||
|
|
||||||
# File names of attachments uploaded through the API, to a release, an issue and
|
# File names of attachments uploaded through the API, to a release, an issue and
|
||||||
# a comment.
|
# a comment.
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
# 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\"}"}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# 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"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
|
||||||
|
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
|
||||||
|
// the score, when its score of the client is a hit, and refuses the request
|
||||||
|
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
|
||||||
|
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
|
||||||
|
// checked in the background, by the request's address, if its history counts
|
||||||
|
// an offence, and the request does not wait for the answer. The score is then
|
||||||
|
// used for each address of the client. Its figures are the score and
|
||||||
|
// SWW_ABUSEIPDB_MIN_SCORE.
|
||||||
|
type abuseIPDB struct{}
|
||||||
|
|
||||||
|
func (abuseIPDB) name() string { return "abuseipdb" }
|
||||||
|
|
||||||
|
func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
if rq.h.config.AbuseIPDBKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client := rq.h.clientGroup(rq.client)
|
||||||
|
held, _ := rq.h.limiter.Client(client)
|
||||||
|
offender := held.History.Offences != ratelimit.Offences{}
|
||||||
|
|
||||||
|
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
|
||||||
|
if !hit {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.abuseIPDBHit = true
|
||||||
|
rq.addTag(config.AbuseIPDBTag)
|
||||||
|
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||||
|
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
||||||
|
|
||||||
|
if rq.h.config.ReputationAction != deny {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionClientDenied,
|
||||||
|
figures: map[string]any{
|
||||||
|
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -42,20 +42,20 @@ var (
|
|||||||
|
|
||||||
// answerHealth answers a request for the health endpoint.
|
// answerHealth answers a request for the health endpoint.
|
||||||
func (rq *request) answerHealth() {
|
func (rq *request) answerHealth() {
|
||||||
rq.line.Action = requestlog.ActionAdmin
|
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||||
// Set here rather than left to Go's server, which would set it only
|
// Set here rather than left to Go's server, which would set it only
|
||||||
// after the log line has taken the response's headers.
|
// after the log line has taken the response's headers.
|
||||||
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
_, _ = io.WriteString(rq.out, "ok\n")
|
_, _ = io.WriteString(rq.out, "ok\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// answerAdmin answers a request for one of servewaf's own endpoints. Each
|
// answerAdmin answers a request for one of discriminator's own endpoints. Each
|
||||||
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is
|
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
|
||||||
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||||
// path does.
|
// path does.
|
||||||
func (rq *request) answerAdmin() {
|
func (rq *request) answerAdmin() {
|
||||||
rq.line.Action = requestlog.ActionAdmin
|
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||||
|
|
||||||
token, answer := rq.endpoint()
|
token, answer := rq.endpoint()
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ type clientAnswer struct {
|
|||||||
Bans []state.BanEntry `json:"bans"`
|
Bans []state.BanEntry `json:"bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// showClient answers GET ClientsPath<ip> with what servewaf knows of the client:
|
// showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
|
||||||
// its counters, its history, which holds its country as last looked up and its
|
// its counters, its history, which holds its country as last looked up and its
|
||||||
// offences, and its bans with their notes.
|
// offences, and its bans with their notes.
|
||||||
func (rq *request) showClient() {
|
func (rq *request) showClient() {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer
|
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
|
||||||
// how a request carries it.
|
// how a request carries it.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
adminBearer = "Bearer " + adminSecret
|
adminBearer = "Bearer " + adminSecret
|
||||||
@@ -40,7 +40,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
|||||||
// An empty token does not match the unset one either.
|
// An empty token does not match the unset one either.
|
||||||
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
||||||
for _, e := range adminEndpoints() {
|
for _, e := range adminEndpoints() {
|
||||||
s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
|
s.ownEndpointRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
|||||||
for _, authorization := range []string{
|
for _, authorization := range []string{
|
||||||
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
||||||
} {
|
} {
|
||||||
got := s.adminRequest(authorization, e.method, e.path, e.body,
|
got := s.ownEndpointRequest(authorization, e.method, e.path, e.body,
|
||||||
http.StatusUnauthorized)
|
http.StatusUnauthorized)
|
||||||
if got.header.Get("WWW-Authenticate") != "Bearer" {
|
if got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||||
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
|
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
|
||||||
@@ -86,13 +86,13 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any other request under /_smallwebwaf/ is not found.
|
// Any other request under /_sww/ is not found.
|
||||||
for _, e := range []adminEndpoint{
|
for _, e := range []adminEndpoint{
|
||||||
{http.MethodPut, servewaf.BansPath, banOtherClient},
|
{http.MethodPut, discriminator.BansPath, banOtherClient},
|
||||||
{http.MethodDelete, servewaf.BansPath, ""},
|
{http.MethodDelete, discriminator.BansPath, ""},
|
||||||
{http.MethodGet, servewaf.BansPath + "/" + otherClient, ""},
|
{http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
|
||||||
{http.MethodPost, servewaf.ClientsPath + otherClient, ""},
|
{http.MethodPost, discriminator.ClientsPath + otherClient, ""},
|
||||||
{http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""},
|
{http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
|
||||||
} {
|
} {
|
||||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -118,10 +118,10 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
Reason: "probes for logins",
|
Reason: "probes for logins",
|
||||||
}
|
}
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK),
|
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
||||||
want)
|
http.StatusOK), want)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||||
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
// Its notes count the request it refused.
|
// Its notes count the request it refused.
|
||||||
want.Notes.Requests, want.Notes.Refused = 1, 1
|
want.Notes.Requests, want.Notes.Refused = 1, 1
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||||
|
|
||||||
// Ten minutes on, lifting the bans on client lifts that one, which is
|
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||||
// kept, marked lifted.
|
// kept, marked lifted.
|
||||||
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
lifted := clk.Now()
|
lifted := clk.Now()
|
||||||
want.Lifted = &lifted
|
want.Lifted = &lifted
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "",
|
wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
|
||||||
http.StatusOK), want)
|
http.StatusOK), want)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||||
|
|
||||||
// No ban on it is active any more.
|
// No ban on it is active any more.
|
||||||
s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound)
|
s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||||
@@ -181,7 +181,8 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
|||||||
want.Expires = &expires
|
want.Expires = &expires
|
||||||
}
|
}
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, body, http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
|
||||||
|
want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +251,8 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
|||||||
"request body too large",
|
"request body too large",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
got := s.admin(http.MethodPost, servewaf.BansPath, tc.body, http.StatusBadRequest)
|
got := s.admin(http.MethodPost, discriminator.BansPath, tc.body,
|
||||||
|
http.StatusBadRequest)
|
||||||
if !strings.Contains(string(got.body), tc.want) {
|
if !strings.Contains(string(got.body), tc.want) {
|
||||||
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
||||||
}
|
}
|
||||||
@@ -274,7 +276,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
// limit of two a minute, and bans it.
|
// limit of two a minute, and bans it.
|
||||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
// Asked about by its address in IPv6 form too.
|
// Asked about by its address in IPv6 form too.
|
||||||
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
||||||
@@ -283,7 +285,8 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
Bans []state.BanEntry `json:"bans"`
|
Bans []state.BanEntry `json:"bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
decode(t, s.admin(http.MethodGet, servewaf.ClientsPath+addr, "", http.StatusOK), &got)
|
decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
|
||||||
|
http.StatusOK), &got)
|
||||||
|
|
||||||
if got.Client == nil {
|
if got.Client == nil {
|
||||||
t.Fatalf("%s: no client", addr)
|
t.Fatalf("%s: no client", addr)
|
||||||
@@ -305,13 +308,15 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
|
|
||||||
// Of an address no request came from and no ban covers, nothing is
|
// Of an address no request came from and no ban covers, nothing is
|
||||||
// known.
|
// known.
|
||||||
got := s.admin(http.MethodGet, servewaf.ClientsPath+"198.51.100.99", "", http.StatusOK)
|
got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "",
|
||||||
|
http.StatusOK)
|
||||||
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
||||||
t.Errorf("an unknown client is answered\n%s", got.body)
|
t.Errorf("an unknown client is answered\n%s", got.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "",
|
||||||
s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "",
|
http.StatusBadRequest)
|
||||||
|
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +329,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for _, e := range adminEndpoints() {
|
for _, e := range adminEndpoints() {
|
||||||
s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
@@ -332,40 +337,40 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens.
|
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
|
||||||
type adminEndpoint struct {
|
type adminEndpoint struct {
|
||||||
method, path, body string
|
method, path, body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN
|
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
|
||||||
// opens: listing the bans, banning otherClient for an hour, lifting the
|
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||||
// bans on otherClient, and asking about otherClient.
|
// bans on otherClient, and asking about otherClient.
|
||||||
func adminEndpoints() []adminEndpoint {
|
func adminEndpoints() []adminEndpoint {
|
||||||
return []adminEndpoint{
|
return []adminEndpoint{
|
||||||
{http.MethodGet, servewaf.BansPath, ""},
|
{http.MethodGet, discriminator.BansPath, ""},
|
||||||
{http.MethodPost, servewaf.BansPath, banOtherClient},
|
{http.MethodPost, discriminator.BansPath, banOtherClient},
|
||||||
{http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""},
|
{http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
|
||||||
{http.MethodGet, servewaf.ClientsPath + otherClient, ""},
|
{http.MethodGet, discriminator.ClientsPath + otherClient, ""},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// admin sends a request with method for path, with body, to servewaf's own
|
// admin sends a request with method for path, with body, to discriminator's own
|
||||||
// address, with the admin token, and checks that it is answered with status, its
|
// address, with the admin token, and checks that it is answered with status, its
|
||||||
// log line's action admin. It returns the answer.
|
// log line's action own_endpoint. It returns the answer.
|
||||||
func (s *sender) admin(method, path, body string, status int) answer {
|
func (s *sender) admin(method, path, body string, status int) answer {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
return s.adminRequest(adminBearer, method, path, body, status)
|
return s.ownEndpointRequest(adminBearer, method, path, body, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminRequest is admin with authorization as the request's Authorization header
|
// ownEndpointRequest is admin with authorization as the request's Authorization
|
||||||
// unless it is "".
|
// header unless it is "".
|
||||||
func (s *sender) adminRequest(
|
func (s *sender) ownEndpointRequest(
|
||||||
authorization, method, path, body string, status int,
|
authorization, method, path, body string, status int,
|
||||||
) answer {
|
) answer {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body))
|
req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
|
||||||
if authorization != "" {
|
if authorization != "" {
|
||||||
req.Header.Set("Authorization", authorization)
|
req.Header.Set("Authorization", authorization)
|
||||||
}
|
}
|
||||||
@@ -375,7 +380,7 @@ func (s *sender) adminRequest(
|
|||||||
|
|
||||||
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||||
s.sent++
|
s.sent++
|
||||||
wantLine(s.t, line, status, requestlog.ActionAdmin)
|
wantLine(s.t, line, status, requestlog.ActionOwnEndpoint)
|
||||||
|
|
||||||
return got
|
return got
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||||
// alertInstance is the instance every alert of these tests gives.
|
// alertInstance is the instance every alert of these tests gives.
|
||||||
alertInstance = "fsn1app1/gitea"
|
alertInstance = "fsn1app1/blog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
||||||
@@ -31,14 +31,14 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
|||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
ban := server.Ledger.Bans(netblock)[0]
|
ban := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
// A request refused under the ban raises no other alert.
|
// A request refused under the ban raises no other alert.
|
||||||
clk.advance(time.Minute)
|
clk.advance(time.Minute)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||||
Netblock: netblock, Cause: bans.CauseLimit,
|
Netblock: netblock, Cause: bans.CauseLimit,
|
||||||
@@ -62,22 +62,24 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
|
|||||||
|
|
||||||
// The probe bans the client for seven days, and its next request makes
|
// The probe bans the client for seven days, and its next request makes
|
||||||
// the ban permanent. The request after that changes nothing.
|
// the ban permanent. The request after that changes nothing.
|
||||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
attackBan := server.Ledger.Bans(netblock)[0]
|
attackBan := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
clk.advance(time.Minute)
|
clk.advance(time.Minute)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||||
|
|
||||||
permanentBan := server.Ledger.Bans(netblock)[0]
|
permanentBan := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||||
|
|
||||||
// Another client's probe after its first ban has run out without a
|
// Another client's probe after its first ban has run out without a
|
||||||
// request makes a permanent ban at once.
|
// request makes a permanent ban at once.
|
||||||
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(otherClient, "/.env", http.StatusForbidden,
|
||||||
|
requestlog.ActionTemporarilyBanned)
|
||||||
clk.advance(7 * 24 * time.Hour)
|
clk.advance(7 * 24 * time.Hour)
|
||||||
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(otherClient, "/.env", http.StatusForbidden,
|
||||||
|
requestlog.ActionPermanentlyBanned)
|
||||||
|
|
||||||
otherBans := server.Ledger.Bans(other)
|
otherBans := server.Ledger.Bans(other)
|
||||||
|
|
||||||
@@ -201,7 +203,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
|||||||
// never sent them, and returns the queue they wait in as well.
|
// never sent them, and returns the queue they wait in as well.
|
||||||
func startWithAlerts(
|
func startWithAlerts(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||||
@@ -210,21 +212,21 @@ func startWithAlerts(
|
|||||||
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||||
func startAppWithAlerts(
|
func startAppWithAlerts(
|
||||||
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := startApp(t, handler)
|
app := startApp(t, handler)
|
||||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
alertWebhookURL: "https://alerts.example/sww",
|
||||||
instanceName: alertInstance,
|
instanceName: alertInstance,
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
||||||
|
|
||||||
s := &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}
|
s := &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}
|
||||||
|
|
||||||
return s, clk, server, queue
|
return s, clk, server, queue
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -13,19 +13,19 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||||
// count.
|
// count.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWWAF_ANOMALY_NET_"
|
anomalyNet = "SWW_ANOMALY_NET_"
|
||||||
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||||
anomalyWatch = "SWWAF_WATCH_"
|
anomalyWatch = "SWW_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -35,9 +35,9 @@ const (
|
|||||||
|
|
||||||
// The other anomaly settings.
|
// The other anomaly settings.
|
||||||
const (
|
const (
|
||||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||||
watchNets = "SWWAF_WATCH_NETS"
|
watchNets = "SWW_WATCH_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -73,7 +73,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
|
|||||||
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
|
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
|
||||||
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
|
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
|
||||||
{
|
{
|
||||||
anomalyWatch, anomaly.ScopeWatch, netip.MustParsePrefix(clientsNet),
|
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
|
||||||
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
|
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
denied = "192.0.2.20" // in SWWAF_DENY_NETS
|
denied = "192.0.2.20" // in SWW_DENY_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
@@ -187,7 +187,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
|||||||
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
||||||
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
||||||
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
||||||
{denied, "/", http.StatusForbidden, requestlog.ActionDenied},
|
{denied, "/", http.StatusForbidden, requestlog.ActionClientDenied},
|
||||||
} {
|
} {
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.request(sent.from, sent.path, sent.status, sent.action)
|
s.request(sent.from, sent.path, sent.status, sent.action)
|
||||||
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
|||||||
// kept.
|
// kept.
|
||||||
geojsURL, _, release := startHeldGeoJS(t)
|
geojsURL, _, release := startHeldGeoJS(t)
|
||||||
|
|
||||||
var server atomic.Pointer[servewaf.Server]
|
var server atomic.Pointer[discriminator.Server]
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
release()
|
release()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,29 +15,29 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||||||
// servewaf has to answer.
|
// discriminator has to answer.
|
||||||
answerTimeLimit = 2 * time.Second
|
answerTimeLimit = 2 * time.Second
|
||||||
// defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT.
|
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
|
||||||
defaultLookupWait = 800 * time.Millisecond
|
defaultLookupWait = 800 * time.Millisecond
|
||||||
// The header limits SPEC.md gives.
|
// The header limits SPEC.md gives.
|
||||||
cookieLimit = 50
|
cookieLimit = 50
|
||||||
headerFieldLimit = 100
|
headerFieldLimit = 100
|
||||||
// cookieHeader is the header the cookie limit counts.
|
// cookieHeader is the header the cookie limit counts.
|
||||||
cookieHeader = "Cookie"
|
cookieHeader = "Cookie"
|
||||||
// tooLarge is how servewaf refuses a request over the header limits.
|
// tooLarge is how discriminator refuses a request over the header limits.
|
||||||
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
||||||
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
||||||
// that unansweredGeoJSURL names too.
|
// that silentGeoJSURL names too.
|
||||||
silentAbuseIPDBURL = "unanswered://abuseipdb/api/v2/check"
|
silentAbuseIPDBURL = "silent://abuseipdb/api/v2/check"
|
||||||
// wholeLimit is the percentage of a rate limit that lowers it not at all.
|
// wholeLimit is the percentage of a rate limit that lowers it not at all.
|
||||||
wholeLimit = 100
|
wholeLimit = 100
|
||||||
// clientsDecision is the CrowdSec decision list holding client alone.
|
// clientsDecision is the CrowdSec decision list holding client alone.
|
||||||
@@ -56,7 +56,7 @@ func cookies(count int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fillHeaders adds headers to a question until the checks see total header
|
// fillHeaders adds headers to a question until the checks see total header
|
||||||
// fields: all but ClientHeader and PeerHeader, which servewaf removes. Go's
|
// fields: all but ClientHeader and PeerHeader, which discriminator removes. Go's
|
||||||
// client sends nothing else but Host, which the limit does not count, once
|
// client sends nothing else but Host, which the limit does not count, once
|
||||||
// User-Agent is set.
|
// User-Agent is set.
|
||||||
func fillHeaders(req *http.Request, total int) {
|
func fillHeaders(req *http.Request, total int) {
|
||||||
@@ -70,7 +70,7 @@ func fillHeaders(req *http.Request, total int) {
|
|||||||
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||||
|
|
||||||
@@ -81,16 +81,28 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
|||||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||||
|
|
||||||
if line.DurationWAF != nil {
|
if line.DurationCoreRuleSetMS != nil {
|
||||||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startDiscriminator(t, "", nil)
|
||||||
|
req := s.question(siteA, client, "/")
|
||||||
|
fillHeaders(req, headerFieldLimit+1)
|
||||||
|
do(t, req)
|
||||||
|
|
||||||
|
wantHeuristic(t, s.out.requestLine(t), "header_limits",
|
||||||
|
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -136,7 +148,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
tc.make(req)
|
tc.make(req)
|
||||||
|
|
||||||
@@ -144,16 +156,16 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
|||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
|
|
||||||
if tc.refused {
|
if tc.refused {
|
||||||
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
if line.DurationWAF == nil {
|
if line.DurationCoreRuleSetMS == nil {
|
||||||
t.Error("the Core Rule Set did not read a request within the limits")
|
t.Error("the Core Rule Set did not read a request within the limits")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -166,16 +178,16 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
|||||||
for _, env := range []map[string]string{
|
for _, env := range []map[string]string{
|
||||||
{wafMode: off},
|
{wafMode: off},
|
||||||
{wafMode: "detect"},
|
{wafMode: "detect"},
|
||||||
{wafMode: block, "SWWAF_WAF_EXEMPT_PATHS": "/"},
|
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
|
||||||
} {
|
} {
|
||||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", env)
|
s := startDiscriminator(t, "", env)
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,17 +195,17 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
|||||||
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{wafMode: block, allowNets: client})
|
s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{mode: observe, wafMode: block})
|
s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||||
|
|
||||||
@@ -204,19 +216,20 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
|||||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
|
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
|
||||||
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
line.DurationCoreRuleSetMS != nil {
|
||||||
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
|
t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none",
|
||||||
|
line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
ask := func() answer {
|
ask := func() answer {
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
@@ -224,11 +237,11 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
|||||||
return do(t, req)
|
return do(t, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
|
||||||
wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny)
|
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
|
|
||||||
lines := s.out.requestLines(t, 2)
|
lines := s.out.requestLines(t, 2)
|
||||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
|
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||||
@@ -237,19 +250,19 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name, path, action string
|
name, path, action string
|
||||||
}{
|
}{
|
||||||
{"a trap path", "/wp-login.php", requestlog.ActionBanned},
|
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
|
||||||
{"a block rule", "/blocked", requestlog.ActionRuleBlocked},
|
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{
|
s := startDiscriminator(t, "", map[string]string{
|
||||||
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
req := s.question(siteA, client, tc.path)
|
req := s.question(siteA, client, tc.path)
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -259,30 +272,30 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startServeWAF(t, geojsURL, map[string]string{
|
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||||
unknownLimitPercent: "0", lookupTimeout: "1h",
|
unknownLimitPercent: "0", lookupTimeout: "1h",
|
||||||
})
|
})
|
||||||
req := s.question(siteA, fromDE, "/")
|
req := s.question(siteA, fromDE, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||||
|
|
||||||
if line.LimitPercent != nil {
|
if line.RateLimitPercent != nil {
|
||||||
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The client is under no ban, and GeoJS places it in a country.
|
// The client is under no ban, and GeoJS places it in a country.
|
||||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startServeWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
s := startDiscriminator(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||||
s.server.GeoJS.Load([]lookup.Answer{{
|
s.server.GeoJS.Load([]lookup.Answer{{
|
||||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||||||
@@ -291,22 +304,23 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
|
|||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
||||||
|
requestlog.ActionClientIsRateLimited)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startServeWAF(t, geojsURL, map[string]string{
|
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
||||||
})
|
})
|
||||||
req := s.question(siteA, fromDE, "/")
|
req := s.question(siteA, fromDE, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
|
||||||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||||||
if asn != asnDE || country != "DE" {
|
if asn != asnDE || country != "DE" {
|
||||||
@@ -315,18 +329,18 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never
|
// newDiscriminatorInBubble makes a discriminator for a synctest bubble: GeoJS never
|
||||||
// answers, a setting needs its answer, and everything is at its default but
|
// answers, a setting needs its answer, and everything is at its default but
|
||||||
// for env. No request to it may use the network.
|
// for env. No request to it may use the network.
|
||||||
func newServeWAFInBubble(
|
func newDiscriminatorInBubble(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*servewaf.Server, *output) {
|
) (*discriminator.Server, *output) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{deniedCountries: "KP"}
|
settings := map[string]string{tagDeny: "country=KP"}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings)
|
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings)
|
||||||
|
|
||||||
return server, out
|
return server, out
|
||||||
}
|
}
|
||||||
@@ -334,13 +348,13 @@ func newServeWAFInBubble(
|
|||||||
// askDirectly hands a question about a request from client straight to server,
|
// askDirectly hands a question about a request from client straight to server,
|
||||||
// as change leaves it, and returns the answer's status and how long it took.
|
// as change leaves it, and returns the answer's status and how long it took.
|
||||||
func askDirectly(
|
func askDirectly(
|
||||||
t *testing.T, server *servewaf.Server, change func(*http.Request),
|
t *testing.T, server *discriminator.Server, change func(*http.Request),
|
||||||
) (int, time.Duration) {
|
) (int, time.Duration) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||||
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
||||||
req.Header.Set(servewaf.ClientHeader, client)
|
req.Header.Set(discriminator.ClientHeader, client)
|
||||||
req.Header.Set(requestIDHeader, "the-id")
|
req.Header.Set(requestIDHeader, "the-id")
|
||||||
change(req)
|
change(req)
|
||||||
|
|
||||||
@@ -353,17 +367,17 @@ func askDirectly(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// banClient bans client at now.
|
// banClient bans client at now.
|
||||||
func banClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
func banClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||||
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// countClient counts one request from client at now for the rate limits.
|
// countClient counts one request from client at now for the rate limits.
|
||||||
func countClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
func countClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||||
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listClient puts client on the blocklist at dropURL.
|
// listClient puts client on the blocklist at dropURL.
|
||||||
func listClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
err := server.Lists.Load([]reputation.List{{
|
err := server.Lists.Load([]reputation.List{{
|
||||||
@@ -375,13 +389,13 @@ func listClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decideOnClient puts client on the CrowdSec decision list.
|
// decideOnClient puts client on the CrowdSec decision list.
|
||||||
func decideOnClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
loadDecisions(t, server, now, clientsDecision)
|
loadDecisions(t, server, now, clientsDecision)
|
||||||
}
|
}
|
||||||
|
|
||||||
// leaveAsItIs prepares nothing.
|
// leaveAsItIs prepares nothing.
|
||||||
func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {}
|
func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
|
||||||
|
|
||||||
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -392,41 +406,45 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
// prepare gets the server ready, at now, before the question.
|
// prepare gets the server ready, at now, before the question.
|
||||||
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||||
// cookie is the request's Cookie header.
|
// cookie is the request's Cookie header.
|
||||||
cookie string
|
cookie string
|
||||||
status int
|
status int
|
||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client},
|
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
||||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a ban",
|
name: "a ban",
|
||||||
prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
|
prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
||||||
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
prepare: countClient, status: forbidden,
|
||||||
|
action: requestlog.ActionClientIsRateLimited,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the rate limits, with a biased threshold",
|
name: "the rate limits, with a biased threshold",
|
||||||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||||||
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
prepare: countClient, status: forbidden,
|
||||||
|
action: requestlog.ActionClientIsRateLimited,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
||||||
prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
|
prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the CrowdSec decision list",
|
name: "the CrowdSec decision list",
|
||||||
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
||||||
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
|
prepare: decideOnClient, status: forbidden,
|
||||||
|
action: requestlog.ActionTemporarilyBanned,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
||||||
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
|
prepare: leaveAsItIs, status: tooLarge,
|
||||||
|
action: requestlog.ActionRequestHeaderTooLarge,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -435,7 +453,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, so that a request that does not wait takes no time at all.
|
// says, so that a request that does not wait takes no time at all.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out := newServeWAFInBubble(t, tc.env)
|
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||||
tc.prepare(t, server, time.Now())
|
tc.prepare(t, server, time.Now())
|
||||||
|
|
||||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||||
@@ -463,7 +481,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
// prepare gets the server ready, at now, before the question.
|
// prepare gets the server ready, at now, before the question.
|
||||||
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||||
status int
|
status int
|
||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
@@ -492,7 +510,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out := newServeWAFInBubble(t, tc.env)
|
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||||
tc.prepare(t, server, time.Now())
|
tc.prepare(t, server, time.Now())
|
||||||
|
|
||||||
status, took := askDirectly(t, server, func(*http.Request) {})
|
status, took := askDirectly(t, server, func(*http.Request) {})
|
||||||
@@ -508,7 +526,9 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) {
|
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
held, askedGeoJS, _ := startHeldGeoJS(t)
|
held, askedGeoJS, _ := startHeldGeoJS(t)
|
||||||
@@ -521,12 +541,14 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin
|
|||||||
|
|
||||||
t.Cleanup(func() { _ = resolver.Close() })
|
t.Cleanup(func() { _ = resolver.Close() })
|
||||||
|
|
||||||
server, out, _ := newServeWAFWith(t, func(params servewaf.Params) *servewaf.Server {
|
server, out, _ := newDiscriminatorWith(t, func(
|
||||||
|
params discriminator.Params,
|
||||||
|
) *discriminator.Server {
|
||||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||||
|
|
||||||
return servewaf.New(params)
|
return discriminator.New(params)
|
||||||
}, held, time.Now, map[string]string{
|
}, held, time.Now, map[string]string{
|
||||||
deniedCountries: "KP", wafMode: block,
|
tagDeny: "country=KP", wafMode: block,
|
||||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||||
abuseIPDBKey: accountKey,
|
abuseIPDBKey: accountKey,
|
||||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic active_ban, and the code that makes bans, which several
|
||||||
|
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"context"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,17 +15,21 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
|
||||||
// with action.
|
// with action.
|
||||||
func (rq *request) banResponse(action string) *refusal {
|
func (rq *request) banResponse(action string) *refusal {
|
||||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||||
}
|
}
|
||||||
|
|
||||||
// banned reports whether a ban on a netblock the client is in covers the
|
// activeBan refuses a client a ban covers: a ban on a netblock the client is
|
||||||
// request at now, and notes for the log line when that ban ends. A
|
// in, in force at now. It notes for the log line when that ban ends. A request
|
||||||
// request that makes the ban permanent, or in observe mode would have,
|
// that makes the ban permanent, or in observe mode would have, raises the alert
|
||||||
// raises the alert for it.
|
// for it. Its figures are the ban's netblock and when it ends.
|
||||||
func (rq *request) banned(now time.Time) bool {
|
type activeBan struct{}
|
||||||
|
|
||||||
|
func (activeBan) name() string { return "active_ban" }
|
||||||
|
|
||||||
|
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||||
check := rq.h.ledger.Check
|
check := rq.h.ledger.Check
|
||||||
if rq.h.config.Observe {
|
if rq.h.config.Observe {
|
||||||
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
||||||
@@ -38,90 +45,39 @@ func (rq *request) banned(now time.Time) bool {
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banned
|
if !banned {
|
||||||
}
|
return nil
|
||||||
|
|
||||||
// limitBroken counts the request for the rate limits at now, notes the
|
|
||||||
// client's counts for the log line, and reports whether the request takes
|
|
||||||
// the client over a rate limit, as its limit percentage lowers it, which
|
|
||||||
// breaks it.
|
|
||||||
func (rq *request) limitBroken(now time.Time) bool {
|
|
||||||
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
|
||||||
rq.limitPercent.percent)
|
|
||||||
rq.line.Counts = counts
|
|
||||||
|
|
||||||
if over {
|
|
||||||
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return over
|
return &verdict{
|
||||||
}
|
action: banAction(ban.Permanent()),
|
||||||
|
figures: map[string]any{
|
||||||
// countBytes counts the request's bytes, as countedBytes gives them, for
|
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
||||||
// the byte limits, once its report has come, and notes the client's byte totals
|
},
|
||||||
// for the log line; its requests stay there as the rate limits counted them.
|
|
||||||
// Only an allowed request has them counted, and only one the rate limits
|
|
||||||
// counted; in observe mode, not one that enforce mode would have refused. Bytes
|
|
||||||
// that take the client over a byte limit, as its limit percentage for the byte
|
|
||||||
// limits lowers it, break it; the response was passed on whole.
|
|
||||||
func (rq *request) countBytes() {
|
|
||||||
if !rq.counted || rq.line.WouldAction != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := rq.h.now()
|
|
||||||
|
|
||||||
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
|
||||||
rq.countedBytes(), rq.bytesPercent.percent)
|
|
||||||
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
|
||||||
rq.line.Counts.HourBytes = counts.HourBytes
|
|
||||||
rq.line.Counts.DayBytes = counts.DayBytes
|
|
||||||
|
|
||||||
if over {
|
|
||||||
rq.banForLimit(now, hit, rq.out.status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// countRefusal counts the request for the error burst once it has been
|
// banAction is the action of a request refused under a ban or for making one:
|
||||||
// answered, if servewaf refused it after a rule file match, a trap path or a
|
// permanent says the ban never runs out.
|
||||||
// Core Rule Set match, and in observe mode if enforce mode would have: more than
|
func banAction(permanent bool) string {
|
||||||
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
if permanent {
|
||||||
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
|
return requestlog.ActionPermanentlyBanned
|
||||||
// not counted, and nothing is while the threshold is off.
|
|
||||||
func (rq *request) countRefusal() {
|
|
||||||
cfg := rq.h.config
|
|
||||||
if cfg.ErrorBurstThreshold == 0 {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
return requestlog.ActionTemporarilyBanned
|
||||||
return
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
now := rq.h.now()
|
|
||||||
|
|
||||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
|
||||||
cfg.ErrorBurstThreshold)
|
|
||||||
if !over {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the client was sent, or in observe mode would have been.
|
|
||||||
status := rq.out.status
|
|
||||||
|
|
||||||
switch rq.line.WouldAction {
|
|
||||||
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
|
|
||||||
status = http.StatusForbidden
|
|
||||||
case requestlog.ActionBanned:
|
|
||||||
status = cfg.BanResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.banForLimit(now, hit, status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// countedBytes returns the request's bytes, once it has ended, as the
|
// countedBytes returns the request's bytes, once it has ended, as the
|
||||||
// byte limits and the anomaly thresholds count them: the response's body
|
// byte limits and the anomaly thresholds count them: the response's body
|
||||||
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says.
|
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
|
||||||
func (rq *request) countedBytes() int64 {
|
func (rq *request) countedBytes() int64 {
|
||||||
response, request := rq.out.bytes, rq.requestBytes
|
response, request := rq.out.bytes, rq.requestBytes
|
||||||
|
|
||||||
@@ -138,7 +94,7 @@ func (rq *request) countedBytes() int64 {
|
|||||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||||
// one hit names, notes the offence for the log line and counts the hit in
|
// one hit names, notes the offence for the log line and counts the hit in
|
||||||
// the metrics. status is what the client was sent, or is sent:
|
// the metrics. status is what the client was sent, or is sent:
|
||||||
// SWWAF_BAN_RESPONSE for a request over a rate limit, what the report says for
|
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||||
// error burst. The ban's notes give the client's limit percentage for a
|
// error burst. The ban's notes give the client's limit percentage for a
|
||||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||||
@@ -150,7 +106,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
|||||||
case ratelimit.KindBytes:
|
case ratelimit.KindBytes:
|
||||||
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
||||||
case ratelimit.KindRefusals:
|
case ratelimit.KindRefusals:
|
||||||
rq.line.LimitHit = requestlog.LimitHitErrorBurst
|
rq.line.LimitHit = requestlog.LimitHitMinuteRefusals
|
||||||
default:
|
default:
|
||||||
rq.line.LimitHit = hit.Window
|
rq.line.LimitHit = hit.Window
|
||||||
}
|
}
|
||||||
@@ -178,9 +134,9 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
|||||||
|
|
||||||
switch hit.Kind {
|
switch hit.Kind {
|
||||||
case ratelimit.KindRequests:
|
case ratelimit.KindRequests:
|
||||||
notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged()
|
notes.LimitPercent, notes.LimitPercentSetting = rq.rateLimitPercent.logged()
|
||||||
case ratelimit.KindBytes:
|
case ratelimit.KindBytes:
|
||||||
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged()
|
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesLimitPercent.logged()
|
||||||
}
|
}
|
||||||
|
|
||||||
if rq.h.config.Observe {
|
if rq.h.config.Observe {
|
||||||
@@ -205,11 +161,11 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
|||||||
// attack, which notes name: the ban rule that matched, or the trap path
|
// attack, which notes name: the ban rule that matched, or the trap path
|
||||||
// asked for. It fills in the rest of the notes. In observe mode it makes
|
// asked for. It fills in the rest of the notes. In observe mode it makes
|
||||||
// no ban, and raises the alert for the ban it would have made, if that
|
// no ban, and raises the alert for the ban it would have made, if that
|
||||||
// alert would be sent.
|
// alert would be sent. It returns the action for that ban.
|
||||||
func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
||||||
return
|
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.ASN = rq.line.ASN
|
notes.ASN = rq.line.ASN
|
||||||
@@ -225,7 +181,7 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return banAction(ban.Permanent())
|
||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
||||||
@@ -234,16 +190,18 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
|||||||
if made {
|
if made {
|
||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return banAction(ban.Permanent())
|
||||||
}
|
}
|
||||||
|
|
||||||
// banForCrowdSec bans the client's netblock at now until decision,
|
// banForCrowdSec bans the client's netblock at now until decision,
|
||||||
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
||||||
// ban, and raises the alert for the ban it would have made, if that alert
|
// ban, and raises the alert for the ban it would have made, if that alert
|
||||||
// would be sent.
|
// would be sent. It returns the action for that ban.
|
||||||
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string {
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
||||||
return
|
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
|
||||||
}
|
}
|
||||||
|
|
||||||
notes := bans.Notes{
|
notes := bans.Notes{
|
||||||
@@ -262,7 +220,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return banAction(ban.Permanent())
|
||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
||||||
@@ -272,12 +230,14 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
|||||||
if made {
|
if made {
|
||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return banAction(ban.Permanent())
|
||||||
}
|
}
|
||||||
|
|
||||||
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||||
// made at now would be sent. In observe mode the ban the request would
|
// made at now would be sent. In observe mode the ban the request would
|
||||||
// have made is worked out only then, at most once per
|
// have made is worked out only then, at most once per
|
||||||
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||||
// netblock's requests, which can mean going through every client.
|
// netblock's requests, which can mean going through every client.
|
||||||
func (rq *request) wouldAlertBan(
|
func (rq *request) wouldAlertBan(
|
||||||
netblock netip.Prefix, now time.Time, cause string,
|
netblock netip.Prefix, now time.Time, cause string,
|
||||||
@@ -343,7 +303,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// netblock is the netblock a ban on client covers: its IPv4 address,
|
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||||
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||||
// counts it in.
|
// counts it in.
|
||||||
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||||
addr := client.Unmap()
|
addr := client.Unmap()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,7 +26,7 @@ const (
|
|||||||
userAgent = "ban-test/1.0"
|
userAgent = "ban-test/1.0"
|
||||||
// permanent is the log line's ban_expires for a permanent ban.
|
// permanent is the log line's ban_expires for a permanent ban.
|
||||||
permanent = "permanent"
|
permanent = "permanent"
|
||||||
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection.
|
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
|
||||||
banResponseClose = "close"
|
banResponseClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
|
|||||||
// client for an hour, the default.
|
// client for an hour, the default.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||||
line.BanExpires != expires {
|
line.BanExpires != expires {
|
||||||
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||||
@@ -51,7 +51,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
|
|||||||
// Every request while the ban lasts is refused.
|
// Every request while the ban lasts is refused.
|
||||||
clk.advance(time.Hour - time.Second)
|
clk.advance(time.Hour - time.Second)
|
||||||
|
|
||||||
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
|
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
|
||||||
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
|
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
|
||||||
"want %s and neither of the others", line.BanExpires, line.Offence,
|
"want %s and neither of the others", line.BanExpires, line.Offence,
|
||||||
@@ -78,7 +78,9 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
breakLimit := func() string {
|
breakLimit := func() string {
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires
|
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
|
return line.BanExpires
|
||||||
}
|
}
|
||||||
wantExpires := func(got string, length time.Duration) {
|
wantExpires := func(got string, length time.Duration) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -89,19 +91,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within
|
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
|
||||||
// SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10*time.Minute + time.Hour)
|
clk.advance(10*time.Minute + time.Hour)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// Later than that, SWWAF_LIMIT_BAN_DURATION again.
|
// Later than that, SWW_LIMIT_BAN_DURATION again.
|
||||||
clk.advance(30*time.Minute + time.Hour + time.Second)
|
clk.advance(30*time.Minute + time.Hour + time.Second)
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10 * time.Minute)
|
clk.advance(10 * time.Minute)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// 90 minutes would be longer than SWWAF_MAX_BAN_DURATION: the ban is
|
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
|
||||||
// permanent.
|
// permanent.
|
||||||
clk.advance(30 * time.Minute)
|
clk.advance(30 * time.Minute)
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clk.advance(365 * 24 * time.Hour)
|
clk.advance(365 * 24 * time.Hour)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||||
@@ -123,10 +125,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
|||||||
// client for an hour.
|
// client for an hour.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Later the same day the client has its whole allowance again: the
|
// Later the same day the client has its whole allowance again: the
|
||||||
@@ -135,7 +137,7 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
|||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||||
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
||||||
@@ -148,8 +150,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
|
|
||||||
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
||||||
const (
|
const (
|
||||||
allowed = "203.0.113.60" // in SWWAF_ALLOW_NETS
|
allowed = "203.0.113.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -164,7 +166,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
nil, []string{otherClient, exempt},
|
nil, []string{otherClient, exempt},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets",
|
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
|
||||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||||
},
|
},
|
||||||
@@ -173,7 +175,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets",
|
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
|
||||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
||||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
||||||
},
|
},
|
||||||
@@ -190,10 +192,10 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(t, "", env)
|
s, _, _ := startWithClock(t, "", env)
|
||||||
|
|
||||||
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
for _, sent := range tc.refused {
|
for _, sent := range tc.refused {
|
||||||
s.get(sent, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sent := range tc.let {
|
for _, sent := range tc.let {
|
||||||
@@ -211,14 +213,14 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
banScopeV4Prefix: "24",
|
banScopeV4Prefix: "24",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
if line.Country != "" {
|
if line.Country != "" {
|
||||||
t.Errorf("log line has country %q, want none", line.Country)
|
t.Errorf("log line has country %q, want none", line.Country)
|
||||||
}
|
}
|
||||||
@@ -228,13 +230,29 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rateLimitPerMinute: "1", banScopeV4Prefix: "24",
|
||||||
|
})
|
||||||
|
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
|
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
wantHeuristic(t, line, "active_ban",
|
||||||
|
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWWAF_BAN_RESPONSE at its default
|
setting string // "" leaves SWW_BAN_RESPONSE at its default
|
||||||
status int // 0 is the connection closed without an answer
|
status int // 0 is the connection closed without an answer
|
||||||
}{
|
}{
|
||||||
{"", http.StatusForbidden},
|
{"", http.StatusForbidden},
|
||||||
@@ -250,7 +268,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -259,11 +277,11 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
|
|
||||||
s, _, _ := startWithClock(t, geojsURL, env)
|
s, _, _ := startWithClock(t, geojsURL, env)
|
||||||
|
|
||||||
s.get(denied, tc.status, requestlog.ActionDenied)
|
s.get(denied, tc.status, requestlog.ActionClientDenied)
|
||||||
s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
|
s.get(fromKP, tc.status, requestlog.ActionClientDenied)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,15 +293,15 @@ func TestBanNotes(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.request(fromDE, "/repo/commits?page=2",
|
s.request(fromDE, "/repo/commits?page=2",
|
||||||
http.StatusForbidden, requestlog.ActionRateLimited)
|
http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix(fromDE + "/32")
|
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||||
want := bans.Ban{
|
want := bans.Ban{
|
||||||
@@ -326,7 +344,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
// The next ban counts this one among the earlier.
|
// The next ban counts this one among the earlier.
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
got = ledger.Bans(netblock)
|
got = ledger.Bans(netblock)
|
||||||
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
@@ -344,15 +362,15 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
|||||||
|
|
||||||
// One ban is held, so otherClient's ban drops client's.
|
// One ban is held, so otherClient's ban drops client's.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clock is the time a test sets, by which smallwebwaf counts requests and
|
// clock is the time a test sets, by which sww counts requests and
|
||||||
// makes bans.
|
// makes bans.
|
||||||
type clock struct {
|
type clock struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -375,14 +393,14 @@ func (c *clock) advance(d time.Duration) {
|
|||||||
c.now = c.now.Add(d)
|
c.now = c.now.Add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startWithClock starts servewaf, and rproxy in front of an app that answers 200,
|
// startWithClock starts discriminator, and rproxy in front of an app that answers 200,
|
||||||
// with the settings in env on top of trusting localhost's
|
// with the settings in env on top of trusting localhost's
|
||||||
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
||||||
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
||||||
// window.
|
// window.
|
||||||
func startWithClock(
|
func startWithClock(
|
||||||
t *testing.T, geojsURL string, env map[string]string,
|
t *testing.T, geojsURL string, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server) {
|
) (*sender, *clock, *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
@@ -392,19 +410,20 @@ func startWithClock(
|
|||||||
|
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||||
|
|
||||||
return &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}, clk, server
|
return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server
|
||||||
}
|
}
|
||||||
|
|
||||||
// sender sends requests to rproxy one after another, each on a connection of its
|
// sender sends requests to rproxy one after another, each on a connection of its
|
||||||
// own, and checks each one's answer and log line. serveWAFAddr is where servewaf's
|
// own, and checks each one's answer and log line. discriminatorAddr is where
|
||||||
// own endpoints are, which adminRequest asks. Every request servewaf gets must
|
// discriminator's own endpoints are, which ownEndpointRequest asks. Every request
|
||||||
// come through the sender, since the log lines are matched to them in order.
|
// discriminator gets must come through the sender, since the log lines are
|
||||||
|
// matched to them in order.
|
||||||
type sender struct {
|
type sender struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
addr string
|
addr string
|
||||||
serveWAFAddr string
|
discriminatorAddr string
|
||||||
out *output
|
out *output
|
||||||
sent int
|
sent int
|
||||||
}
|
}
|
||||||
|
|
||||||
// get sends a GET request for / from the client at from.
|
// get sends a GET request for / from the client at from.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// whole is the percentage of each limit a client gets when no biased
|
||||||
|
// threshold lowers its limits.
|
||||||
|
const whole = 100
|
||||||
|
|
||||||
|
// percentage is a client's limit percentage for the rate limits or for
|
||||||
|
// the byte limits, as the biased thresholds give it, and the setting that
|
||||||
|
// gave it: "" with whole when none lowers that kind of limit.
|
||||||
|
type percentage struct {
|
||||||
|
percent int64
|
||||||
|
setting string
|
||||||
|
}
|
||||||
|
|
||||||
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
|
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
|
||||||
|
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
|
||||||
|
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
||||||
|
// then needed before its request goes on.
|
||||||
|
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||||
|
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||||
|
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitPercentages returns the client's limit percentages, for the rate
|
||||||
|
// limits and for the byte limits, by its tags, its AS number and country
|
||||||
|
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
|
||||||
|
// AbuseIPDB that list it. Each is the lowest of those the settings give
|
||||||
|
// it, the first of them in the order below when several are lowest: the
|
||||||
|
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
|
||||||
|
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
||||||
|
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
|
||||||
|
// other tags, for a client without a country, unless its request skipped
|
||||||
|
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
||||||
|
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
|
||||||
|
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
||||||
|
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
|
||||||
|
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
|
||||||
|
// take the place of the first three for an AS number or a country they
|
||||||
|
// list.
|
||||||
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
|
cfg := rq.h.config
|
||||||
|
asn, country := rq.line.ASN, rq.line.Country
|
||||||
|
|
||||||
|
unknown := percentage{percent: whole}
|
||||||
|
if country == "" && !rq.skippedLookupWait {
|
||||||
|
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched := percentage{percent: whole}
|
||||||
|
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||||
|
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
|
||||||
|
}
|
||||||
|
|
||||||
|
blocklisted := percentage{percent: whole}
|
||||||
|
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||||
|
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
|
||||||
|
}
|
||||||
|
|
||||||
|
reputationListed := percentage{percent: whole}
|
||||||
|
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||||
|
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
|
||||||
|
|
||||||
|
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
|
||||||
|
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
|
||||||
|
|
||||||
|
otherTags := percentage{percent: whole}
|
||||||
|
|
||||||
|
for _, tag := range rq.line.Tags {
|
||||||
|
if tag != asnTag(asn) && tag != countryTag(country) {
|
||||||
|
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
|
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||||
|
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||||
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
|
||||||
|
reputationListed),
|
||||||
|
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// given returns the percentage percents, the setting named setting, gives
|
||||||
|
// code, an AS number, a country or a tag, or whole when it does not list code.
|
||||||
|
func given(percents map[string]int64, code, setting string) percentage {
|
||||||
|
percent, listed := percents[code]
|
||||||
|
if !listed {
|
||||||
|
return percentage{percent: whole}
|
||||||
|
}
|
||||||
|
|
||||||
|
return percentage{percent, setting}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowest returns the lowest of percentages below whole, the first of them
|
||||||
|
// when several are lowest, or whole when none is below it.
|
||||||
|
func lowest(percentages ...percentage) percentage {
|
||||||
|
low := percentage{percent: whole}
|
||||||
|
|
||||||
|
for _, p := range percentages {
|
||||||
|
if p.percent < low.percent {
|
||||||
|
low = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return low
|
||||||
|
}
|
||||||
|
|
||||||
|
// logged returns p as the log line and the notes of a ban give it: its
|
||||||
|
// percent and setting, or nil and "" for whole, which they leave out.
|
||||||
|
func (p percentage) logged() (*int64, string) {
|
||||||
|
if p.percent == whole {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return &p.percent, p.setting
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,18 +15,17 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The biased thresholds.
|
// The biased thresholds.
|
||||||
const (
|
const (
|
||||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -35,6 +34,11 @@ const (
|
|||||||
asnDEHalf = asnDE + ":50"
|
asnDEHalf = asnDE + ":50"
|
||||||
asnDEQuarter = asnDE + ":25"
|
asnDEQuarter = asnDE + ":25"
|
||||||
countryDEHalf = "de:50"
|
countryDEHalf = "de:50"
|
||||||
|
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
|
||||||
|
asnTagDEHalf = "asn=64496:50"
|
||||||
|
asnTagDEQuarter = "asn=64496:25"
|
||||||
|
countryTagDEHalf = "country=DE:50"
|
||||||
|
countryTagDEQuarter = "country=DE:25"
|
||||||
// noCountry is in an AS of its own, AS64500, and in no country.
|
// noCountry is in an AS of its own, AS64500, and in no country.
|
||||||
noCountry = "192.0.2.80"
|
noCountry = "192.0.2.80"
|
||||||
// fourAMinute is the rate limit these tests set: half of it is 2
|
// fourAMinute is the rate limit these tests set: half of it is 2
|
||||||
@@ -54,8 +58,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{asnLimitPercent, asnDEHalf, fromDE},
|
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||||
{countryLimitPercent, countryDEHalf, fromDE},
|
{tagLimitPercent, countryTagDEHalf, fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
@@ -72,17 +76,19 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{http.StatusOK, requestlog.ActionForward},
|
{http.StatusOK, requestlog.ActionForward},
|
||||||
{http.StatusOK, requestlog.ActionForward},
|
{http.StatusOK, requestlog.ActionForward},
|
||||||
{http.StatusForbidden, requestlog.ActionRateLimited},
|
{http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
||||||
} {
|
} {
|
||||||
line := s.get(tc.from, sent.status, sent.action)
|
line := s.get(tc.from, sent.status, sent.action)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
"50 from "+tc.setting)
|
"50 from "+tc.setting)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fromKP, which no setting lists, has the whole limit.
|
// fromKP, which no setting lists, has the whole limit.
|
||||||
for range 3 {
|
for range 3 {
|
||||||
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
|
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
none)
|
none)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -96,8 +102,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{asnLimitPercent, asnDEHalf, fromDE},
|
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||||
{countryLimitPercent, "DE:50", fromDE},
|
{tagLimitPercent, "Country=de:50", fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
{asnBytesPercent, "as64496:50", fromDE},
|
{asnBytesPercent, "as64496:50", fromDE},
|
||||||
{countryBytesPercent, countryDEHalf, fromDE},
|
{countryBytesPercent, countryDEHalf, fromDE},
|
||||||
@@ -115,7 +121,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
wantPercent(t, "bytes_limit_percent",
|
||||||
|
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||||
"50 from "+tc.setting)
|
"50 from "+tc.setting)
|
||||||
|
|
||||||
// fromKP, which no setting lists, has the whole limit.
|
// fromKP, which no setting lists, has the whole limit.
|
||||||
@@ -124,7 +131,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
|
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
|
wantPercent(t, "bytes_limit_percent",
|
||||||
|
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,9 +143,9 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
// limitPercent and bytesPercent are the log line's, as percentText
|
// rateLimitPercent and bytesLimitPercent are the log line's, as
|
||||||
// gives them, and limitHit is its limit_hit.
|
// percentText gives them, and limitHit is its limit_hit.
|
||||||
limitPercent, bytesPercent, limitHit string
|
rateLimitPercent, bytesLimitPercent, limitHit string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"lowering the byte limits alone",
|
"lowering the byte limits alone",
|
||||||
@@ -151,13 +159,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back",
|
"raising the byte limits back",
|
||||||
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
|
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||||
"50 from " + asnLimitPercent, none, "",
|
"50 from " + tagLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back, by country",
|
"raising the byte limits back, by country",
|
||||||
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
|
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
||||||
"50 from " + countryLimitPercent, none, "",
|
"50 from " + tagLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -173,10 +181,12 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
|
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
tc.limitPercent)
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
tc.rateLimitPercent)
|
||||||
tc.bytesPercent)
|
wantPercent(t, "bytes_limit_percent",
|
||||||
|
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||||
|
tc.bytesLimitPercent)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,19 +194,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
|
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
|
||||||
|
|
||||||
// The first request breaks the limit, and bans the client; the log line
|
// The first request breaks the limit, and bans the client; the log line
|
||||||
// gives the 0.
|
// gives the 0.
|
||||||
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
if line.fields["limit_percent"] != float64(0) ||
|
if line.fields["rate_limit_percent"] != float64(0) ||
|
||||||
line.fields["limit_percent_setting"] != asnLimitPercent {
|
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
|
||||||
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
t.Errorf("log line has rate_limit_percent %v from %v, want 0 from %s",
|
||||||
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
line.fields["rate_limit_percent"], line.fields["rate_limit_percent_setting"],
|
||||||
asnLimitPercent)
|
unknownLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLowestPercentageApplies(t *testing.T) {
|
func TestLowestPercentageApplies(t *testing.T) {
|
||||||
@@ -206,34 +216,34 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
from string
|
from string
|
||||||
// want is the log line's limit_percent, as percentText gives it.
|
// want is the log line's rate_limit_percent, as percentText gives it.
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"the country's",
|
"the country's",
|
||||||
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
|
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
|
||||||
fromDE, "25 from " + countryLimitPercent,
|
fromDE, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's",
|
"the AS number's",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
|
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
|
||||||
fromDE, "25 from " + asnLimitPercent,
|
fromDE, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's, the first of two alike",
|
"the AS number's, the first of two alike",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
|
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
|
||||||
fromDE, "25 from " + asnLimitPercent,
|
fromDE, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"that for a client without a country",
|
"that for a client without a country",
|
||||||
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
|
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
||||||
noCountry, "25 from " + unknownLimitPercent,
|
noCountry, "25 from " + unknownLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||||
"the AS number's, for a client without a country",
|
"the AS number's, for a client without a country",
|
||||||
map[string]string{asnLimitPercent: "AS64500:25"},
|
map[string]string{tagLimitPercent: "asn=64500:25"},
|
||||||
noCountry, "25 from " + asnLimitPercent,
|
noCountry, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -246,8 +256,9 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
// A quarter of 4 requests a minute: the second breaks the limit.
|
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||||
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
|
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionRateLimited)
|
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
tc.want)
|
tc.want)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -265,7 +276,7 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test
|
|||||||
for _, from := range []string{unplaced, "10.0.0.8"} {
|
for _, from := range []string{unplaced, "10.0.0.8"} {
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One in a country has the whole limit.
|
// One in a country has the whole limit.
|
||||||
@@ -280,7 +291,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||||
map[string]string{unknownLimitPercent: "0"})
|
map[string]string{unknownLimitPercent: "0"})
|
||||||
|
|
||||||
// Once the time the request waits for its answer is up, the client
|
// Once the time the request waits for its answer is up, the client
|
||||||
@@ -289,8 +300,9 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
|||||||
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
"0 from "+unknownLimitPercent)
|
"0 from "+unknownLimitPercent)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -304,8 +316,12 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
setting, value string
|
setting, value string
|
||||||
waits bool
|
waits bool
|
||||||
}{
|
}{
|
||||||
{asnLimitPercent, asnDEHalf, true},
|
{tagLimitPercent, asnTagDEHalf, true},
|
||||||
{countryLimitPercent, countryDEHalf, true},
|
{tagLimitPercent, countryTagDEHalf, true},
|
||||||
|
{tagDeny, denyKP, true},
|
||||||
|
// A tag the lookup does not give needs no answer.
|
||||||
|
{tagLimitPercent, "vpn:50", false},
|
||||||
|
{tagDeny, "vpn", false},
|
||||||
{asnBytesPercent, asnDEHalf, true},
|
{asnBytesPercent, asnDEHalf, true},
|
||||||
{countryBytesPercent, countryDEHalf, true},
|
{countryBytesPercent, countryDEHalf, true},
|
||||||
{unknownLimitPercent, "99", true},
|
{unknownLimitPercent, "99", true},
|
||||||
@@ -319,8 +335,10 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||||
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
|
map[string]string{
|
||||||
|
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
||||||
|
})
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|
||||||
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
||||||
@@ -363,15 +381,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
{
|
{
|
||||||
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
||||||
"a rate limit",
|
"a rate limit",
|
||||||
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
|
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
|
||||||
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
3, http.StatusForbidden, requestlog.ActionClientIsRateLimited,
|
||||||
"requests per minute over the limit of 3", "25 from " + asnLimitPercent,
|
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The byte limits' percentage, not the rate limits'.
|
// The byte limits' percentage, not the rate limits'.
|
||||||
"a byte limit",
|
"a byte limit",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
|
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
||||||
asnBytesPercent: asnDEHalf,
|
asnBytesPercent: asnDEHalf,
|
||||||
},
|
},
|
||||||
0, http.StatusOK, requestlog.ActionForward,
|
0, http.StatusOK, requestlog.ActionForward,
|
||||||
@@ -419,7 +437,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
// clock.
|
// clock.
|
||||||
func startWithLookups(
|
func startWithLookups(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *servewaf.Server, *alerts.Queue) {
|
) (*sender, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
s, _, server, queue := startWithLookupsAndClock(t, env)
|
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||||
@@ -434,7 +452,7 @@ func startWithLookups(
|
|||||||
// country, and no other address.
|
// country, and no other address.
|
||||||
func startWithLookupsAndClock(
|
func startWithLookupsAndClock(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
@@ -463,11 +481,13 @@ func (s *sender) uploadFrom(from string) logLine {
|
|||||||
// serveFromDE hands a question about a request from fromDE with method and body
|
// serveFromDE hands a question about a request from fromDE with method and body
|
||||||
// straight to server's handler, without the network, and returns once it is
|
// straight to server's handler, without the network, and returns once it is
|
||||||
// answered.
|
// answered.
|
||||||
func serveFromDE(t *testing.T, server *servewaf.Server, method string, body io.Reader) {
|
func serveFromDE(
|
||||||
|
t *testing.T, server *discriminator.Server, method string, body io.Reader,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||||
req.Header.Set(servewaf.ClientHeader, fromDE)
|
req.Header.Set(discriminator.ClientHeader, fromDE)
|
||||||
req.Header.Set(requestIDHeader, "the-id")
|
req.Header.Set(requestIDHeader, "the-id")
|
||||||
|
|
||||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
@@ -485,7 +505,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// percentText gives a limit percentage and the setting that gave it as
|
// percentText gives a limit percentage and the setting that gave it as
|
||||||
// text, such as "50 from SWWAF_ASN_LIMIT_PERCENT", or none when both are
|
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
|
||||||
// left out.
|
// left out.
|
||||||
func percentText(percent *int64, setting string) string {
|
func percentText(percent *int64, setting string) string {
|
||||||
switch {
|
switch {
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
|
||||||
|
// through the reputation lists.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blocklists notes the blocklists that list the client, as noteListed does,
|
||||||
|
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it
|
||||||
|
// lowers the client's limits instead (see limitPercentages), and being log, it
|
||||||
|
// does nothing more. Its figure is the blocklists that list the client.
|
||||||
|
type blocklists struct{}
|
||||||
|
|
||||||
|
func (blocklists) name() string { return "blocklists" }
|
||||||
|
|
||||||
|
func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
listedBy := rq.h.lists.ListedBy(rq.client)
|
||||||
|
rq.blocklisted = len(listedBy) > 0
|
||||||
|
rq.noteListed(listedBy, "listed by a blocklist")
|
||||||
|
|
||||||
|
if !rq.refusedByBlocklists(listedBy) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionClientDenied,
|
||||||
|
figures: map[string]any{"lists": listedBy},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refusedByBlocklists reports whether the heuristic blocklists refuses a
|
||||||
|
// client, given listedBy, the blocklists that list it: it does when one lists
|
||||||
|
// it and SWW_BLOCKLIST_ACTION is deny.
|
||||||
|
func (rq *request) refusedByBlocklists(listedBy []string) bool {
|
||||||
|
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
|
||||||
|
// and biased thresholds through the limiter and limitPercentages.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// byteLimits counts the request's bytes, as countedBytes gives them, for the
|
||||||
|
// byte limits, once its report has come, and notes the client's byte totals for
|
||||||
|
// the log line; its requests stay there as the rate limits counted them. Only
|
||||||
|
// an allowed request has them counted, and only one the rate limits counted; in
|
||||||
|
// observe mode, not one that enforce mode would have refused. Bytes that take
|
||||||
|
// the client over a byte limit, as its limit percentage for the byte limits
|
||||||
|
// lowers it, break it, which bans the client; the response was passed on whole.
|
||||||
|
// Its figures are the window, the count and the limit.
|
||||||
|
type byteLimits struct{}
|
||||||
|
|
||||||
|
func (byteLimits) name() string { return "byte_limits" }
|
||||||
|
|
||||||
|
func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||||
|
if !rq.counted || rq.line.WouldAction != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
||||||
|
rq.countedBytes(), rq.bytesLimitPercent.percent)
|
||||||
|
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
||||||
|
rq.line.Counts.HourBytes = counts.HourBytes
|
||||||
|
rq.line.Counts.DayBytes = counts.DayBytes
|
||||||
|
|
||||||
|
if !over {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.banForLimit(now, hit, rq.out.status)
|
||||||
|
|
||||||
|
return &verdict{figures: limitFigures(hit)}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -19,13 +19,13 @@ import (
|
|||||||
|
|
||||||
// The byte limit settings.
|
// The byte limit settings.
|
||||||
const (
|
const (
|
||||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWWAF_BYTES_COUNT"
|
bytesCount = "SWW_BYTES_COUNT"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of SWWAF_BYTES_COUNT.
|
// The values of SWW_BYTES_COUNT.
|
||||||
const (
|
const (
|
||||||
countResponse = "response"
|
countResponse = "response"
|
||||||
countRequest = "request"
|
countRequest = "request"
|
||||||
@@ -40,7 +40,7 @@ const (
|
|||||||
// byteLimit is the byte limit these tests set, as a setting: a request
|
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||||
// with a body and its answer, 100 bytes, go over it.
|
// with a body and its answer, 100 bytes, go over it.
|
||||||
byteLimit = "99"
|
byteLimit = "99"
|
||||||
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE.
|
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
|
||||||
minuteBytes = "minute_bytes"
|
minuteBytes = "minute_bytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,14 +90,29 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
|||||||
line.BanExpires, tc.window, expires)
|
line.BanExpires, tc.window, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
wantMetric(t, s.scrape(), `smallwebwaf_rate_limit_hits_total{`+
|
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
|
||||||
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
|
||||||
|
|
||||||
|
line, _ := s.download()
|
||||||
|
wantAfterAnswerHeuristic(t, line, "", "null")
|
||||||
|
|
||||||
|
// Two answers of 70 bytes are over the limit of 99.
|
||||||
|
line, _ = s.download()
|
||||||
|
wantHeuristic(t, line, "", "null")
|
||||||
|
wantAfterAnswerHeuristic(t, line, "byte_limits",
|
||||||
|
`{"window": "minute", "count": 140, "limit": 99}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -110,7 +125,7 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
|||||||
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||||
@@ -132,7 +147,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
|||||||
answerBytes, minuteBytes, expires)
|
answerBytes, minuteBytes, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||||
@@ -161,7 +176,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
|||||||
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +218,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,8 +227,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
@@ -371,7 +386,7 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
|
|||||||
// counted, and the byte limit is not broken. Its line gives the bytes
|
// counted, and the byte limit is not broken. Its line gives the bytes
|
||||||
// counted before it.
|
// counted before it.
|
||||||
line := s.upload()
|
line := s.upload()
|
||||||
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
|
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
|
||||||
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
|
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
|
|||||||
|
|
||||||
// clientGroup is the client a request is counted toward: its IPv4
|
// clientGroup is the client a request is counted toward: its IPv4
|
||||||
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
||||||
// length SWWAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
||||||
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
||||||
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
||||||
addr = addr.Unmap()
|
addr = addr.Unmap()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
|
||||||
|
// and SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// coreRuleSet inspects the request with the Core Rule Set, unless
|
||||||
|
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
||||||
|
// pathExempt decides, and notes the rules it matched and its score in the
|
||||||
|
// log line, and the rules in the metrics. A score at or over
|
||||||
|
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
|
||||||
|
// raises the core_rule_set_match alert, and in block mode refuses the request with 403,
|
||||||
|
// which is an offence its client's history counts. Its figures are the score
|
||||||
|
// and the threshold.
|
||||||
|
type coreRuleSet struct{}
|
||||||
|
|
||||||
|
func (coreRuleSet) name() string { return "core_rule_set" }
|
||||||
|
|
||||||
|
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
cfg := rq.h.config
|
||||||
|
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// A question's body has been read into memory, so that reading it here
|
||||||
|
// cannot fail.
|
||||||
|
result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
|
||||||
|
rq.requestBytes = int64(len(read))
|
||||||
|
|
||||||
|
rq.line.DurationCoreRuleSetMS = new(requestlog.Milliseconds(time.Since(start)))
|
||||||
|
rq.line.CoreRuleSetRuleIDs = result.RuleIDs
|
||||||
|
rq.line.CoreRuleSetScore = &result.Score
|
||||||
|
|
||||||
|
for _, id := range result.RuleIDs {
|
||||||
|
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
threshold := cfg.WAFAnomalyThreshold
|
||||||
|
if threshold == 0 || result.Score < threshold {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.addTag(config.ExploitSignatureTag)
|
||||||
|
rq.alertCoreRuleSetMatch(result)
|
||||||
|
|
||||||
|
if cfg.WAFMode == config.WAFModeDetect {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.blockedByCoreRuleSet = true
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionBlockedByCoreRuleSet,
|
||||||
|
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
|
||||||
|
// which the Core Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
|
||||||
|
// detail gives the rule ids, the score, the method and the path with the
|
||||||
|
// query, and, for a request that is not refused for it, the mode: detect,
|
||||||
|
// or observe in observe mode.
|
||||||
|
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
||||||
|
detail := map[string]any{
|
||||||
|
"rule_ids": result.RuleIDs,
|
||||||
|
"score": result.Score,
|
||||||
|
"method": rq.in.Method,
|
||||||
|
"path": rq.in.URL.RequestURI(),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case rq.h.config.WAFMode == config.WAFModeDetect:
|
||||||
|
detail["mode"] = config.WAFModeDetect
|
||||||
|
case rq.h.config.Observe:
|
||||||
|
detail["mode"] = "observe"
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventCoreRuleSetMatch,
|
||||||
|
Host: rq.site,
|
||||||
|
Client: rq.client,
|
||||||
|
Netblock: rq.h.clientGroup(rq.client),
|
||||||
|
ASN: rq.line.ASN,
|
||||||
|
ASName: rq.line.ASName,
|
||||||
|
Country: rq.line.Country,
|
||||||
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
+115
-61
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and
|
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
||||||
// its two modes that inspect requests.
|
// its two modes that inspect requests.
|
||||||
const (
|
const (
|
||||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
// formData is the type of a form's body.
|
// formData is the type of a form's body.
|
||||||
const formData = "application/x-www-form-urlencoded"
|
const formData = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
// sizeLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||||
// setting.
|
// setting.
|
||||||
const (
|
const (
|
||||||
sizeLimit = 1 << 10
|
sizeLimit = 1 << 10
|
||||||
@@ -37,30 +37,72 @@ const (
|
|||||||
|
|
||||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
// sqlInjection asks for / with an SQL injection in its query, which only
|
||||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD.
|
// SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||||
|
|
||||||
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
|
||||||
// that it has duration_waf, or with no score, that it has none of the
|
// core_rule_set_score, and that it has duration_core_rule_set_ms, or with no
|
||||||
// three: the Core Rule Set did not inspect the request.
|
// score, that it has none of the three: the Core Rule Set did not inspect the
|
||||||
func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
// request.
|
||||||
|
func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if !slices.Equal(line.WAFRuleIDs, ruleIDs) {
|
if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) {
|
||||||
t.Errorf("log line has waf_rule_ids %v, want %v", line.WAFRuleIDs, ruleIDs)
|
t.Errorf("log line has core_rule_set_rule_ids %v, want %v",
|
||||||
|
line.CoreRuleSetRuleIDs, ruleIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case score == nil && (line.WAFScore != nil || line.DurationWAF != nil):
|
case score == nil &&
|
||||||
t.Errorf("log line has waf_score %v and duration_waf %v, want neither",
|
(line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil):
|
||||||
line.fields["waf_score"], line.fields["duration_waf"])
|
t.Errorf("log line has core_rule_set_score %v and "+
|
||||||
case score != nil && (line.WAFScore == nil || *line.WAFScore != *score):
|
"duration_core_rule_set_ms %v, want neither",
|
||||||
t.Errorf("log line has waf_score %v, want %d", line.fields["waf_score"], *score)
|
line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"])
|
||||||
case score != nil && line.DurationWAF == nil:
|
case score != nil &&
|
||||||
t.Error("log line has no duration_waf")
|
(line.CoreRuleSetScore == nil || *line.CoreRuleSetScore != *score):
|
||||||
|
t.Errorf("log line has core_rule_set_score %v, want %d",
|
||||||
|
line.fields["core_rule_set_score"], *score)
|
||||||
|
case score != nil && line.DurationCoreRuleSetMS == nil:
|
||||||
|
t.Error("log line has no duration_core_rule_set_ms")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
mode string
|
||||||
|
status int
|
||||||
|
action string
|
||||||
|
}{
|
||||||
|
{block, block, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet},
|
||||||
|
{detect, detect, http.StatusOK, requestlog.ActionForward},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
||||||
|
|
||||||
|
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
|
||||||
|
"exploitsignature")
|
||||||
|
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
wafMode: detect, tagDeny: "exploitsignature",
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
|
requestlog.ActionClientDenied)
|
||||||
|
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -97,19 +139,29 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
|
|||||||
mode, action string
|
mode, action string
|
||||||
status int
|
status int
|
||||||
}{
|
}{
|
||||||
{block, requestlog.ActionWAFBlocked, http.StatusForbidden},
|
{block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden},
|
||||||
{detect, requestlog.ActionForward, http.StatusOK},
|
{detect, requestlog.ActionForward, http.StatusOK},
|
||||||
} {
|
} {
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
||||||
|
|
||||||
line := s.requestWithHeader(client, attack.path, attack.header,
|
line := s.requestWithHeader(client, attack.path, attack.header,
|
||||||
tc.status, tc.action)
|
tc.status, tc.action)
|
||||||
wantWAF(t, line, &attack.score, attack.ruleIDs...)
|
wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||||
|
|
||||||
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
|
requestlog.ActionBlockedByCoreRuleSet)
|
||||||
|
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -117,7 +169,7 @@ func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
|||||||
|
|
||||||
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
|
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
|
||||||
http.StatusOK, requestlog.ActionForward)
|
http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, new(0))
|
wantCoreRuleSet(t, line, new(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
||||||
@@ -125,7 +177,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block,
|
wafMode: block,
|
||||||
@@ -134,29 +186,29 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS
|
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
|
||||||
// exempts, are not inspected.
|
// exempts, are not inspected.
|
||||||
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantCoreRuleSet(t, line, nil)
|
||||||
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
|
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
|
||||||
requestlog.ActionForward)
|
requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantCoreRuleSet(t, line, nil)
|
||||||
|
|
||||||
// The prefix is matched as rate limit exempt paths are: a path that
|
// The prefix is matched as rate limit exempt paths are: a path that
|
||||||
// goes up and out of it is inspected.
|
// goes up and out of it is inspected.
|
||||||
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
||||||
requestlog.ActionWAFBlocked)
|
requestlog.ActionBlockedByCoreRuleSet)
|
||||||
wantWAF(t, line, new(25), 930100, 930110, 942100)
|
wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100)
|
||||||
|
|
||||||
// A request a rule file refuses is not inspected.
|
// A request a rule file refuses is not inspected.
|
||||||
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionBlockedByBlockRule)
|
||||||
wantWAF(t, line, nil)
|
wantCoreRuleSet(t, line, nil)
|
||||||
|
|
||||||
// With SWWAF_WAF_MODE off, no request is.
|
// With SWW_WAF_MODE off, no request is.
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
||||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantCoreRuleSet(t, line, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnomalyThreshold(t *testing.T) {
|
func TestAnomalyThreshold(t *testing.T) {
|
||||||
@@ -170,13 +222,14 @@ func TestAnomalyThreshold(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, new(5), 942100)
|
wantCoreRuleSet(t, line, new(5), 942100)
|
||||||
}
|
}
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block, wafAnomalyThreshold: "5",
|
wafMode: block, wafAnomalyThreshold: "5",
|
||||||
})
|
})
|
||||||
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
|
requestlog.ActionBlockedByCoreRuleSet)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
||||||
@@ -219,7 +272,7 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||||
line, _ := s.requestWithBody(request.method, client, request.path,
|
line, _ := s.requestWithBody(request.method, client, request.path,
|
||||||
request.header, body, http.StatusOK, requestlog.ActionForward)
|
request.header, body, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, new(0))
|
wantCoreRuleSet(t, line, new(0))
|
||||||
|
|
||||||
// A list given replaces the default.
|
// A list given replaces the default.
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{
|
s, _, _ = startWithClock(t, "", map[string]string{
|
||||||
@@ -227,12 +280,12 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|||||||
})
|
})
|
||||||
score := 5 * len(request.ruleIDs)
|
score := 5 * len(request.ruleIDs)
|
||||||
line, _ = s.requestWithBody(request.method, client, request.path,
|
line, _ = s.requestWithBody(request.method, client, request.path,
|
||||||
request.header, body, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||||
wantWAF(t, line, &score, request.ruleIDs...)
|
wantCoreRuleSet(t, line, &score, request.ruleIDs...)
|
||||||
|
|
||||||
// And switches off the rules it lists.
|
// And switches off the rules it lists.
|
||||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, new(0))
|
wantCoreRuleSet(t, line, new(0))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,14 +301,14 @@ func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||||
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
|
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||||
http.StatusOK, requestlog.ActionForward)
|
http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, new(0))
|
wantCoreRuleSet(t, line, new(0))
|
||||||
|
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{
|
s, _, _ = startWithClock(t, "", map[string]string{
|
||||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
||||||
})
|
})
|
||||||
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
|
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||||
http.StatusForbidden, requestlog.ActionWAFBlocked)
|
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||||
wantWAF(t, line, new(5), 942100)
|
wantCoreRuleSet(t, line, new(5), 942100)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
||||||
@@ -345,32 +398,33 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
|||||||
})
|
})
|
||||||
|
|
||||||
for range 2 {
|
for range 2 {
|
||||||
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
|
requestlog.ActionBlockedByCoreRuleSet)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The third refusal in a minute breaks the error burst, and bans the
|
// The third refusal in a minute breaks the error burst, and bans the
|
||||||
// client.
|
// client.
|
||||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
requestlog.ActionWAFBlocked)
|
requestlog.ActionBlockedByCoreRuleSet)
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst ||
|
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
||||||
line.Offence != requestlog.OffenceLimit {
|
line.Offence != requestlog.OffenceLimit {
|
||||||
t.Errorf("log line has limit_hit %q and offence %q, want error_burst and limit",
|
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit",
|
||||||
line.LimitHit, line.Offence)
|
line.LimitHit, line.Offence)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
|
want := ratelimit.Offences{Limit: 1, BlockedByCoreRuleSet: 3}
|
||||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
if offences := historyOf(t, server, client).Offences; offences != want {
|
||||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
`sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3)
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+
|
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+
|
||||||
`instance="app",status_class="4xx"}`, 3)
|
`instance="app",status_class="4xx"}`, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,9 +448,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
`sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`)
|
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
||||||
@@ -405,17 +459,17 @@ func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
|||||||
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
|
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionWAFBlocked)
|
wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet)
|
||||||
wantWAF(t, line, new(5), 942100)
|
wantCoreRuleSet(t, line, new(5), 942100)
|
||||||
|
|
||||||
// It is an offence as in enforce mode.
|
// It is an offence as in enforce mode.
|
||||||
want := ratelimit.Offences{WAFBlocked: 1}
|
want := ratelimit.Offences{BlockedByCoreRuleSet: 1}
|
||||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
if offences := historyOf(t, server, client).Offences; offences != want {
|
||||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -429,7 +483,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"block", map[string]string{wafMode: block},
|
"block", map[string]string{wafMode: block},
|
||||||
http.StatusForbidden, requestlog.ActionWAFBlocked, "",
|
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"detect", map[string]string{wafMode: detect},
|
"detect", map[string]string{wafMode: detect},
|
||||||
@@ -464,11 +518,11 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
|||||||
wantAlerts(t, queue, alerts.Alert{
|
wantAlerts(t, queue, alerts.Alert{
|
||||||
Instance: alertInstance,
|
Instance: alertInstance,
|
||||||
Time: clk.Now(),
|
Time: clk.Now(),
|
||||||
Event: alerts.EventWAFBlock,
|
Event: alerts.EventCoreRuleSetMatch,
|
||||||
Host: appHost,
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(client),
|
Client: netip.MustParseAddr(client),
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||||
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
|
||||||
|
// does not hold the client's country as it was looked up. A client without a
|
||||||
|
// country, or whose country cannot be found, is refused. Its figures are the
|
||||||
|
// country and the setting that refused it.
|
||||||
|
type countries struct{}
|
||||||
|
|
||||||
|
func (countries) name() string { return "countries" }
|
||||||
|
|
||||||
|
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||||
|
country := rq.line.Country
|
||||||
|
|
||||||
|
if len(allowed) == 0 || slices.Contains(allowed, country) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionCountryDenied,
|
||||||
|
figures: map[string]any{
|
||||||
|
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
refused []string
|
refused []string
|
||||||
}{
|
}{
|
||||||
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
|
|
||||||
{
|
{
|
||||||
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"},
|
"with a country in SWW_TAG_DENY",
|
||||||
|
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -90,6 +90,23 @@ func TestCountryLists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||||
|
lookupTimeout: "1h", allowedCountries: "de",
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
|
wantHeuristic(t, line, "countries",
|
||||||
|
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||||
|
|
||||||
|
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
|
wantHeuristic(t, line, "countries",
|
||||||
|
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -100,9 +117,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
|||||||
})
|
})
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
deniedCountries: "kp",
|
allowedCountries: "de",
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||||
@@ -185,7 +202,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
}{
|
}{
|
||||||
{"no setting needs the lookup", nil},
|
{"no setting needs the lookup", nil},
|
||||||
{"a country list is set", map[string]string{deniedCountries: "kp"}},
|
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -245,11 +262,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"not in SWWAF_ALLOW_NETS", "",
|
"not in SWW_ALLOW_NETS", "",
|
||||||
http.StatusForbidden, requestlog.ActionCountryDenied,
|
http.StatusForbidden, requestlog.ActionCountryDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"in SWWAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
"in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
||||||
http.StatusOK, requestlog.ActionForward,
|
http.StatusOK, requestlog.ActionForward,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -279,7 +296,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
||||||
// each in an AS of its own, and no other address. It returns its URL, and
|
// each in an AS of its own, and no other address. It returns its URL, and
|
||||||
// what returns the addresses it has been asked about. A test that needs
|
// what returns the addresses it has been asked about. A test that needs
|
||||||
// the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an
|
// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
|
||||||
// hour, whether or not a request waits for the answer: on the default, a
|
// hour, whether or not a request waits for the answer: on the default, a
|
||||||
// hold-up of the test process can abandon the request to the stand-in, and
|
// hold-up of the test process can abandon the request to the stand-in, and
|
||||||
// leave the client unknown.
|
// leave the client unknown.
|
||||||
@@ -350,7 +367,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
||||||
// smallwebwaf reads.
|
// sww reads.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // GeoJS's own names
|
//nolint:tagliatelle // GeoJS's own names
|
||||||
type geojsAnswer struct {
|
type geojsAnswer struct {
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
|
||||||
|
// through the reputation lists.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// crowdSec refuses a request when a decision of the CrowdSec decision list on
|
||||||
|
// the client is in force at now. It then adds the tag crowdsec, notes the
|
||||||
|
// list, as noteHit does, and bans the client until that decision ends. Its
|
||||||
|
// figures are the decision's scenario and when it ends.
|
||||||
|
type crowdSec struct{}
|
||||||
|
|
||||||
|
func (crowdSec) name() string { return "crowdsec" }
|
||||||
|
|
||||||
|
func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||||
|
decision, listed := rq.crowdSecDecisionInForce(now)
|
||||||
|
if !listed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.addTag(config.CrowdSecTag)
|
||||||
|
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
||||||
|
"listed by the CrowdSec decision list")
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: rq.banForCrowdSec(now, decision),
|
||||||
|
figures: map[string]any{
|
||||||
|
"scenario": decision.Scenario,
|
||||||
|
"expires": requestlog.FormatTime(decision.Expires),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crowdSecDecisionInForce returns the decision of the CrowdSec decision list on
|
||||||
|
// the client that is in force at now, and reports whether there is one, which
|
||||||
|
// the heuristic crowdsec refuses the request for.
|
||||||
|
func (rq *request) crowdSecDecisionInForce(
|
||||||
|
now time.Time,
|
||||||
|
) (reputation.Decision, bool) {
|
||||||
|
return rq.h.lists.CrowdSecDecision(rq.client, now)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,17 +9,17 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
||||||
// test puts in the copy of its decision list, at decisionsURL, that it
|
// test puts in the copy of its decision list, at decisionsURL, that it
|
||||||
// needs, as reputation.json would at start.
|
// needs, as reputation.json would at start.
|
||||||
const (
|
const (
|
||||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
lapi = "http://crowdsec.example:8080"
|
lapi = "http://crowdsec.example:8080"
|
||||||
decisionsURL = lapi + "/v1/decisions"
|
decisionsURL = lapi + "/v1/decisions"
|
||||||
bouncerKey = "crowdsec-key-0123456789abcdef"
|
bouncerKey = "crowdsec-key-0123456789abcdef"
|
||||||
@@ -39,7 +39,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
||||||
|
|
||||||
// Its first request is refused, and bans it until the decision ends.
|
// Its first request is refused, and bans it until the decision ends.
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
wantReputation(t, line, decisionsURL)
|
wantReputation(t, line, decisionsURL)
|
||||||
|
|
||||||
if line.BanExpires != expires {
|
if line.BanExpires != expires {
|
||||||
@@ -72,7 +72,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
// ban, and once it has ended the client is let through.
|
// ban, and once it has ended the client is let through.
|
||||||
clk.advance(4*time.Hour - time.Second)
|
clk.advance(4*time.Hour - time.Second)
|
||||||
|
|
||||||
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
wantReputation(t, line)
|
wantReputation(t, line)
|
||||||
|
|
||||||
if line.BanExpires != expires {
|
if line.BanExpires != expires {
|
||||||
@@ -87,14 +87,30 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
|
wantMetric(t, metrics, `sww_bans_made_total{cause="crowdsec",`+
|
||||||
`instance="`+alertInstance+`"}`, 1)
|
`instance="`+alertInstance+`"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
||||||
float64(fetched.Unix()))
|
float64(fetched.Unix()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, _ := startWithAlerts(t, map[string]string{
|
||||||
|
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
||||||
|
})
|
||||||
|
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
||||||
|
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
||||||
|
`"value": "`+client+`"}]`)
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
|
||||||
|
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
|
||||||
|
wantTags(t, line, "crowdsec")
|
||||||
|
}
|
||||||
|
|
||||||
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -113,9 +129,9 @@ func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T
|
|||||||
// Just before its end, the decision bans a client in the netblock, and
|
// Just before its end, the decision bans a client in the netblock, and
|
||||||
// one on an IPv6 address bans the address's group, the /64.
|
// one on an IPv6 address bans the address's group, the /64.
|
||||||
clk.advance(time.Minute - time.Nanosecond)
|
clk.advance(time.Minute - time.Nanosecond)
|
||||||
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned)
|
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned)
|
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned)
|
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
// Once it has ended, it bans no other client, and the bans it made end
|
// Once it has ended, it bans no other client, and the bans it made end
|
||||||
// with it.
|
// with it.
|
||||||
@@ -148,7 +164,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|||||||
`"value": "`+client+`"}]`)
|
`"value": "`+client+`"}]`)
|
||||||
|
|
||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||||
wantReputation(t, line, decisionsURL)
|
wantReputation(t, line, decisionsURL)
|
||||||
|
|
||||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
@@ -168,7 +184,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|||||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
||||||
// reputation.json would at start.
|
// reputation.json would at start.
|
||||||
func loadDecisions(
|
func loadDecisions(
|
||||||
t *testing.T, server *servewaf.Server, fetched time.Time, answer string,
|
t *testing.T, server *discriminator.Server, fetched time.Time, answer string,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic deny_nets. It reads SWW_DENY_NETS.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of
|
||||||
|
// the setting the client is in.
|
||||||
|
type denyNets struct{}
|
||||||
|
|
||||||
|
func (denyNets) name() string { return "deny_nets" }
|
||||||
|
|
||||||
|
func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
for _, netblock := range rq.h.config.DenyNets {
|
||||||
|
if netblock.Contains(rq.client) {
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionClientDenied,
|
||||||
|
figures: map[string]any{figureNetblock: netblock.String()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,18 +18,18 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The headers of rproxy's question and of servewaf's answer, as
|
// The headers of rproxy's question and of discriminator's answer, as
|
||||||
// "servewaf" in SPEC.md gives them.
|
// "discriminator" in SPEC.md gives them.
|
||||||
const (
|
const (
|
||||||
// ClientHeader marks a request as rproxy's question, and gives the
|
// ClientHeader marks a request as rproxy's question, and gives the
|
||||||
// visitor's address.
|
// visitor's address.
|
||||||
ClientHeader = "X-Smallwebwaf-Client"
|
ClientHeader = "X-Sww-Client"
|
||||||
// PeerHeader gives the address of rproxy's own TCP peer.
|
// PeerHeader gives the address of rproxy's own TCP peer.
|
||||||
PeerHeader = "X-Smallwebwaf-Peer"
|
PeerHeader = "X-Sww-Peer"
|
||||||
// ContentLengthHeader gives the body length the visitor announced.
|
// ContentLengthHeader gives the body length the visitor announced.
|
||||||
ContentLengthHeader = "X-Smallwebwaf-Content-Length"
|
ContentLengthHeader = "X-Sww-Content-Length"
|
||||||
// DispositionHeader gives servewaf's answer.
|
// DispositionHeader gives discriminator's answer.
|
||||||
DispositionHeader = "X-Smallwebwaf-Disposition"
|
DispositionHeader = "X-Sww-Disposition"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of DispositionHeader.
|
// The values of DispositionHeader.
|
||||||
@@ -39,26 +40,19 @@ const (
|
|||||||
// headers and body.
|
// headers and body.
|
||||||
DispositionDeny = "deny"
|
DispositionDeny = "deny"
|
||||||
// DispositionClose tells rproxy to close the visitor's connection
|
// DispositionClose tells rproxy to close the visitor's connection
|
||||||
// without an answer, which SWWAF_BAN_RESPONSE asks for when it is close.
|
// without an answer, which SWW_BAN_RESPONSE asks for when it is close.
|
||||||
// servewaf closing the question's connection instead would read as
|
// discriminator closing the question's connection instead would read as
|
||||||
// servewaf being down, and rproxy would then let the request through.
|
// discriminator being down, and rproxy would then let the request through.
|
||||||
DispositionClose = "close"
|
DispositionClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReportPath is where rproxy sends the report of each request servewaf
|
// ReportPath is where rproxy sends the report of each request discriminator
|
||||||
// answered.
|
// answered.
|
||||||
const ReportPath = "/_smallwebwaf/report"
|
const ReportPath = "/_sww/report"
|
||||||
|
|
||||||
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
|
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
|
||||||
const QuestionBodyMaxBytes = 64 << 10
|
const QuestionBodyMaxBytes = 64 << 10
|
||||||
|
|
||||||
// The header limits: the most cookies and header fields a request may have
|
|
||||||
// when the checks reach the Core Rule Set. Real clients never reach them.
|
|
||||||
const (
|
|
||||||
maxCookies = 50
|
|
||||||
maxHeaderFields = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// maxAwaitingReport is the most answered requests held until their
|
// maxAwaitingReport is the most answered requests held until their
|
||||||
// reports come. Past it, the request answered longest ago is dropped.
|
// reports come. Past it, the request answered longest ago is dropped.
|
||||||
@@ -76,11 +70,11 @@ const (
|
|||||||
// maxReportedMilliseconds is the longest duration a report may give,
|
// maxReportedMilliseconds is the longest duration a report may give,
|
||||||
// about 31 years, so that it fits a time.Duration.
|
// about 31 years, so that it fits a time.Duration.
|
||||||
maxReportedMilliseconds = 1e12
|
maxReportedMilliseconds = 1e12
|
||||||
// serveWAFTimeout bounds how long a request to servewaf takes to arrive,
|
// discriminatorTimeout bounds how long a request to discriminator takes to
|
||||||
// and its answer to leave, and serveWAFIdleTimeout how long a kept-open
|
// arrive, and its answer to leave, and discriminatorIdleTimeout how long a
|
||||||
// connection waits for its next request.
|
// kept-open connection waits for its next request.
|
||||||
serveWAFTimeout = 60 * time.Second
|
discriminatorTimeout = 60 * time.Second
|
||||||
serveWAFIdleTimeout = 120 * time.Second
|
discriminatorIdleTimeout = 120 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -95,15 +89,15 @@ var (
|
|||||||
//
|
//
|
||||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||||
type Report struct {
|
type Report struct {
|
||||||
RequestID string `json:"request_id"`
|
RequestID string `json:"request_id"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
RequestBytes int64 `json:"request_bytes"`
|
RequestBytes int64 `json:"request_bytes"`
|
||||||
ResponseBytes int64 `json:"response_bytes"`
|
ResponseBytes int64 `json:"response_bytes"`
|
||||||
DurationTotal float64 `json:"duration_total"`
|
DurationTotalMS float64 `json:"duration_total_ms"`
|
||||||
DurationUpstreamTotal float64 `json:"duration_upstream_total"`
|
DurationUpstreamTotalMS float64 `json:"duration_upstream_total_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the server `smallwebwaf servewaf` runs. It answers
|
// New returns the server `sww discriminator` runs. It answers
|
||||||
// rproxy's questions, takes rproxy's reports and answers its own endpoints. It
|
// rproxy's questions, takes rproxy's reports and answers its own endpoints. It
|
||||||
// passes nothing to an app. rproxy applies the size and time limits to the
|
// passes nothing to an app. rproxy applies the size and time limits to the
|
||||||
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
|
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
|
||||||
@@ -117,9 +111,9 @@ func New(params Params) *Server {
|
|||||||
Addr: params.Config.ListenAddr,
|
Addr: params.Config.ListenAddr,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
|
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
|
||||||
ReadTimeout: serveWAFTimeout,
|
ReadTimeout: discriminatorTimeout,
|
||||||
WriteTimeout: serveWAFTimeout,
|
WriteTimeout: discriminatorTimeout,
|
||||||
IdleTimeout: serveWAFIdleTimeout,
|
IdleTimeout: discriminatorIdleTimeout,
|
||||||
},
|
},
|
||||||
Ledger: h.ledger,
|
Ledger: h.ledger,
|
||||||
Limiter: h.limiter,
|
Limiter: h.limiter,
|
||||||
@@ -129,12 +123,14 @@ func New(params Params) *Server {
|
|||||||
Lists: h.lists,
|
Lists: h.lists,
|
||||||
DNSBL: h.dnsbl,
|
DNSBL: h.dnsbl,
|
||||||
AbuseIPDB: h.abuseIPDB,
|
AbuseIPDB: h.abuseIPDB,
|
||||||
|
RouteWatch: h.routeWatch,
|
||||||
Metrics: h.metrics,
|
Metrics: h.metrics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP handles one request to servewaf: a request carrying ClientHeader is
|
// ServeHTTP handles one request to discriminator: a request carrying
|
||||||
// rproxy's question, and any other is for servewaf itself.
|
// ClientHeader is rproxy's question, and any other is for discriminator
|
||||||
|
// itself.
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if len(r.Header.Values(ClientHeader)) > 0 {
|
if len(r.Header.Values(ClientHeader)) > 0 {
|
||||||
h.answerQuestion(w, r)
|
h.answerQuestion(w, r)
|
||||||
@@ -147,48 +143,20 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
||||||
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
||||||
// its client: it is over the header limits, a blocklist lists its client while
|
// its client: the heuristic crowdsec, header_limits, blocklists or rate_limits
|
||||||
// SWWAF_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
|
// will refuse it, as a function in that heuristic's file says. In observe mode,
|
||||||
// over a whole rate limit. In observe mode, which refuses nothing, none is.
|
// which refuses nothing, none is. rproxy lets a request through when
|
||||||
// rproxy lets a request through when servewaf answers too late.
|
// discriminator answers too late.
|
||||||
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
||||||
if rq.h.config.Observe {
|
if rq.h.config.Observe {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
lists := rq.h.lists
|
_, decided := rq.crowdSecDecisionInForce(now)
|
||||||
_, decided := lists.CrowdSecDecision(rq.client, now)
|
|
||||||
|
|
||||||
return decided || rq.overHeaderLimits() ||
|
return decided || overHeaderLimits(rq.headerCounts()) ||
|
||||||
(rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) ||
|
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
|
||||||
(rq.countedByRateLimits() &&
|
rq.overAWholeRateLimit(now)
|
||||||
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
|
|
||||||
}
|
|
||||||
|
|
||||||
// overHeaderLimits reports whether servewaf refuses the request for its
|
|
||||||
// headers: it has more than maxHeaderFields fields, each value of a header sent
|
|
||||||
// twice being one, or more than maxCookies cookies, each part of a Cookie header
|
|
||||||
// between semicolons being one unless it is empty or only spaces and tabs. The
|
|
||||||
// Core Rule Set runs each of its rules on each cookie, so that thousands of small
|
|
||||||
// cookies keep it busy for over a second. It trims only spaces and control
|
|
||||||
// characters, of which a header value can hold only tabs, so a part of Unicode
|
|
||||||
// spaces is a cookie to it.
|
|
||||||
func (rq *request) overHeaderLimits() bool {
|
|
||||||
fields, cookies := 0, 0
|
|
||||||
|
|
||||||
for _, values := range rq.in.Header {
|
|
||||||
fields += len(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, value := range rq.in.Header.Values("Cookie") {
|
|
||||||
for part := range strings.SplitSeq(value, ";") {
|
|
||||||
if strings.Trim(part, " \t") != "" {
|
|
||||||
cookies++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fields > maxHeaderFields || cookies > maxCookies
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// answerQuestion runs the checks on the visitor's request a question
|
// answerQuestion runs the checks on the visitor's request a question
|
||||||
@@ -212,25 +180,25 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer rq.finish()
|
defer rq.finish()
|
||||||
|
|
||||||
rq.site = r.Host
|
rq.site = r.Host
|
||||||
rq.line.ServeWAFLine = requestlog.ServeWAFLineDecision
|
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
|
||||||
refused := rq.check(r.Context())
|
refused := rq.check(r.Context())
|
||||||
rq.checked = time.Now()
|
rq.checked = time.Now()
|
||||||
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
|
underOwnPrefix := strings.HasPrefix(r.URL.Path, ownEndpointPrefix)
|
||||||
|
|
||||||
if refused == nil && !underOwnPrefix {
|
if refused == nil && !underOwnPrefix {
|
||||||
// In observe mode, what enforce mode would have refused counts here.
|
// In observe mode, what enforce mode would have refused counts here.
|
||||||
defer rq.countRefusal()
|
defer rq.runAfterAnswer(r.Context())
|
||||||
|
|
||||||
rq.allow()
|
rq.allow()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The last deferred runs first: countRefusal before addToHistory, so that a
|
// The last deferred runs first: runAfterAnswer before addToHistory, so that
|
||||||
// broken error burst is in the client's history.
|
// a broken refusal limit is in the client's history.
|
||||||
defer rq.addToHistory()
|
defer rq.addToHistory()
|
||||||
defer rq.countAnomalies()
|
defer rq.countAnomalies()
|
||||||
defer rq.countRefusal()
|
defer rq.runAfterAnswer(r.Context())
|
||||||
|
|
||||||
if refused != nil {
|
if refused != nil {
|
||||||
rq.deny(*refused)
|
rq.deny(*refused)
|
||||||
@@ -238,15 +206,15 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The app never sees this prefix. servewaf's own endpoints are at its
|
// The app never sees this prefix. discriminator's own endpoints are at its
|
||||||
// own address, so this answers as a path with no endpoint does.
|
// own address, so this answers as a path with no endpoint does.
|
||||||
rq.line.Action = requestlog.ActionAdmin
|
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||||
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
||||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readQuestion returns the visitor's address and the body a question carries,
|
// readQuestion returns the visitor's address and the body a question carries,
|
||||||
// and whether servewaf takes the question. It answers a question it does not
|
// and whether discriminator takes the question. It answers a question it does not
|
||||||
// take with 400 and no disposition, which rproxy treats as it treats no
|
// take with 400 and no disposition, which rproxy treats as it treats no
|
||||||
// answer. The id of a question it takes is held from here on, without a
|
// answer. The id of a question it takes is held from here on, without a
|
||||||
// decision yet, so that another question with that id is refused while this
|
// decision yet, so that another question with that id is refused while this
|
||||||
@@ -289,7 +257,7 @@ func (h *handler) readQuestion(
|
|||||||
|
|
||||||
held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
|
held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
|
||||||
if held {
|
if held {
|
||||||
h.refuseQuestion(w, requestIDHeader+" is the id of a request servewaf holds")
|
h.refuseQuestion(w, requestIDHeader+" is the id of a request discriminator holds")
|
||||||
|
|
||||||
return netip.Addr{}, nil, false
|
return netip.Addr{}, nil, false
|
||||||
}
|
}
|
||||||
@@ -297,17 +265,17 @@ func (h *handler) readQuestion(
|
|||||||
return client, body, true
|
return client, body, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// refuseQuestion answers a question servewaf does not take, for reason.
|
// refuseQuestion answers a question discriminator does not take, for reason.
|
||||||
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
|
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
|
||||||
h.processLog.Warn("a question is refused", "reason", reason)
|
h.processLog.Warn("a question is refused", "reason", reason)
|
||||||
http.Error(w, reason, http.StatusBadRequest)
|
http.Error(w, reason, http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the
|
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the
|
||||||
// log line are to see it: without the headers rproxy adds for servewaf, with
|
// log line are to see it: without the headers rproxy adds for discriminator,
|
||||||
// body, which has been read of r, and with the length the visitor announced,
|
// with body, which has been read of r, and with the length the visitor
|
||||||
// as -1 when it announced none. The question's own length covers only the part
|
// announced, as -1 when it announced none. The question's own length covers
|
||||||
// of the body it carries.
|
// only the part of the body it carries.
|
||||||
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
|
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
|
||||||
sent := r.Clone(r.Context())
|
sent := r.Clone(r.Context())
|
||||||
announced := sent.Header.Get(ContentLengthHeader)
|
announced := sent.Header.Get(ContentLengthHeader)
|
||||||
@@ -334,7 +302,7 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// allow answers a question with DispositionAllow and, while
|
// allow answers a question with DispositionAllow and, while
|
||||||
// SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
// SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
||||||
func (rq *request) allow() {
|
func (rq *request) allow() {
|
||||||
rq.allowed = true
|
rq.allowed = true
|
||||||
|
|
||||||
@@ -360,7 +328,7 @@ func (rq *request) deny(r refusal) {
|
|||||||
rq.refused = &r
|
rq.refused = &r
|
||||||
|
|
||||||
if r.status == 0 {
|
if r.status == 0 {
|
||||||
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
// SWW_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
||||||
// status stays 0: the visitor is sent nothing.
|
// status stays 0: the visitor is sent nothing.
|
||||||
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
||||||
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
||||||
@@ -372,7 +340,7 @@ func (rq *request) deny(r refusal) {
|
|||||||
http.Error(rq.out, http.StatusText(r.status), r.status)
|
http.Error(rq.out, http.StatusText(r.status), r.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// answerOwnEndpoint answers a request to servewaf's own address. No check
|
// answerOwnEndpoint answers a request to discriminator's own address. No check
|
||||||
// runs on it: the address is on the private network, where the peers are
|
// runs on it: the address is on the private network, where the peers are
|
||||||
// rproxy and the admin's tools, not visitors.
|
// rproxy and the admin's tools, not visitors.
|
||||||
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -412,7 +380,7 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Remove reports true to one report only, of several that name the id. A
|
// Remove reports true to one report only, of several that name the id. A
|
||||||
// question still being handled is held as nil.
|
// question still being handled is held as nil.
|
||||||
if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
|
if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
|
||||||
h.finishWith(held, report)
|
h.finishWith(r.Context(), held, report)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
@@ -437,7 +405,7 @@ func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
|
|||||||
|
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"request_id", "status", "request_bytes", "response_bytes",
|
"request_id", "status", "request_bytes", "response_bytes",
|
||||||
"duration_total", "duration_upstream_total",
|
"duration_total_ms", "duration_upstream_total_ms",
|
||||||
} {
|
} {
|
||||||
value, there := fields[name]
|
value, there := fields[name]
|
||||||
if !there || string(value) == "null" {
|
if !there || string(value) == "null" {
|
||||||
@@ -463,11 +431,11 @@ func (report *Report) checkRanges() error {
|
|||||||
return fmt.Errorf("%w: request_bytes", errReportValue)
|
return fmt.Errorf("%w: request_bytes", errReportValue)
|
||||||
case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes:
|
case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes:
|
||||||
return fmt.Errorf("%w: response_bytes", errReportValue)
|
return fmt.Errorf("%w: response_bytes", errReportValue)
|
||||||
case report.DurationTotal < 0 || report.DurationTotal > maxReportedMilliseconds:
|
case report.DurationTotalMS < 0 || report.DurationTotalMS > maxReportedMilliseconds:
|
||||||
return fmt.Errorf("%w: duration_total", errReportValue)
|
return fmt.Errorf("%w: duration_total_ms", errReportValue)
|
||||||
case report.DurationUpstreamTotal < 0 ||
|
case report.DurationUpstreamTotalMS < 0 ||
|
||||||
report.DurationUpstreamTotal > maxReportedMilliseconds:
|
report.DurationUpstreamTotalMS > maxReportedMilliseconds:
|
||||||
return fmt.Errorf("%w: duration_upstream_total", errReportValue)
|
return fmt.Errorf("%w: duration_upstream_total_ms", errReportValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -475,11 +443,11 @@ func (report *Report) checkRanges() error {
|
|||||||
|
|
||||||
// finishWith finishes a request with what its report says, and writes the
|
// finishWith finishes a request with what its report says, and writes the
|
||||||
// request's last log line, which holds its decision's fields and the report's.
|
// request's last log line, which holds its decision's fields and the report's.
|
||||||
// For an allowed request it first counts the request's bytes, and adds it to its
|
// For an allowed request it first runs the heuristics of onReport, and adds the
|
||||||
// client's history, the anomaly counters and the metrics, with the status and
|
// request to its client's history, the anomaly counters and the metrics, with
|
||||||
// bytes the report gives. A denied request's counting was finished at its
|
// the status and bytes the report gives. A denied request's counting was
|
||||||
// decision.
|
// finished at its decision. ctx is the report's own context.
|
||||||
func (h *handler) finishWith(held *decision, report Report) {
|
func (h *handler) finishWith(ctx context.Context, held *decision, report Report) {
|
||||||
rq := &request{
|
rq := &request{
|
||||||
decision: *held,
|
decision: *held,
|
||||||
h: h,
|
h: h,
|
||||||
@@ -487,22 +455,22 @@ func (h *handler) finishWith(held *decision, report Report) {
|
|||||||
requestBytes: report.RequestBytes,
|
requestBytes: report.RequestBytes,
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.line.ServeWAFLine = requestlog.ServeWAFLineReport
|
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineReport
|
||||||
rq.line.Status = report.Status
|
rq.line.Status = report.Status
|
||||||
rq.line.RequestBytes = report.RequestBytes
|
rq.line.RequestBytes = report.RequestBytes
|
||||||
rq.line.ResponseBytes = report.ResponseBytes
|
rq.line.ResponseBytes = report.ResponseBytes
|
||||||
rq.line.DurationTotal = report.DurationTotal
|
rq.line.DurationTotalMS = report.DurationTotalMS
|
||||||
|
|
||||||
if rq.allowed {
|
if rq.allowed {
|
||||||
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
|
rq.line.DurationUpstreamTotalMS = &report.DurationUpstreamTotalMS
|
||||||
|
|
||||||
rq.countBytes()
|
rq.runBanningHeuristics(ctx, onReport())
|
||||||
rq.countAnomalies()
|
rq.countAnomalies()
|
||||||
rq.addToHistory()
|
rq.addToHistory()
|
||||||
|
|
||||||
rq.h.metrics.RequestReported(&rq.line,
|
rq.h.metrics.RequestReported(&rq.line,
|
||||||
time.Duration(report.DurationTotal*float64(time.Millisecond)),
|
time.Duration(report.DurationTotalMS*float64(time.Millisecond)),
|
||||||
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond)))
|
time.Duration(report.DurationUpstreamTotalMS*float64(time.Millisecond)))
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.writeLine()
|
rq.writeLine()
|
||||||
+238
-226
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
|
||||||
|
// verdicts through the DNSBL checker.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
||||||
|
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
|
||||||
|
// the request when SWW_REPUTATION_ACTION is deny. Being
|
||||||
|
// limit, it lowers the client's limits instead (see limitPercentages), and
|
||||||
|
// being log, it does nothing more. A zone without a verdict on the client is
|
||||||
|
// asked about it in the background, and the request does not wait for the
|
||||||
|
// answer. Its figure is the zones that list the client, their keys masked.
|
||||||
|
type dnsbl struct{}
|
||||||
|
|
||||||
|
func (dnsbl) name() string { return "dnsbl" }
|
||||||
|
|
||||||
|
func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
|
||||||
|
rq.dnsblListed = len(listedBy) > 0
|
||||||
|
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
||||||
|
|
||||||
|
for _, zone := range listedBy {
|
||||||
|
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionClientDenied,
|
||||||
|
figures: map[string]any{"zones": listedBy},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic header_limits. It reads no setting: its limits are fixed.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The header limits: the most cookies and header fields a request may have
|
||||||
|
// when the checks reach the Core Rule Set. Real clients never reach them.
|
||||||
|
const (
|
||||||
|
maxCookies = 50
|
||||||
|
maxHeaderFields = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// headerLimits refuses a request over the header limits, with 431. Its figures
|
||||||
|
// are the request's header fields and cookies, and the limit of each.
|
||||||
|
type headerLimits struct{}
|
||||||
|
|
||||||
|
func (headerLimits) name() string { return "header_limits" }
|
||||||
|
|
||||||
|
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
fields, cookies := rq.headerCounts()
|
||||||
|
if !overHeaderLimits(fields, cookies) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionRequestHeaderTooLarge,
|
||||||
|
figures: map[string]any{
|
||||||
|
"fields": fields, "fields_limit": maxHeaderFields,
|
||||||
|
"cookies": cookies, "cookies_limit": maxCookies,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overHeaderLimits reports whether a request with these counts of header fields
|
||||||
|
// and cookies, as headerCounts counts them, is over the header limits: it has
|
||||||
|
// more than maxHeaderFields fields or more than maxCookies cookies.
|
||||||
|
func overHeaderLimits(fields, cookies int) bool {
|
||||||
|
return fields > maxHeaderFields || cookies > maxCookies
|
||||||
|
}
|
||||||
|
|
||||||
|
// headerCounts returns the request's header fields, each value of a header sent
|
||||||
|
// twice being one, and its cookies, each part of a Cookie header between
|
||||||
|
// semicolons being one unless it is empty or only spaces and tabs. The Core
|
||||||
|
// Rule Set runs each of its rules on each cookie, so that thousands of small
|
||||||
|
// cookies keep it busy for over a second. It trims only spaces and control
|
||||||
|
// characters, of which a header value can hold only tabs, so a part of Unicode
|
||||||
|
// spaces is a cookie to it.
|
||||||
|
func (rq *request) headerCounts() (int, int) {
|
||||||
|
fields, cookies := 0, 0
|
||||||
|
|
||||||
|
for _, values := range rq.in.Header {
|
||||||
|
fields += len(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, value := range rq.in.Header.Values("Cookie") {
|
||||||
|
for part := range strings.SplitSeq(value, ";") {
|
||||||
|
if strings.Trim(part, " \t") != "" {
|
||||||
|
cookies++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields, cookies
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||||
@@ -21,7 +21,8 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for range healthChecks {
|
for range healthChecks {
|
||||||
got := s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK)
|
got := s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "",
|
||||||
|
http.StatusOK)
|
||||||
|
|
||||||
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||||
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// To add a heuristic, write one file with a type that implements heuristic,
|
||||||
|
// and add one line for it to one of the lists below. Its settings go in
|
||||||
|
// config.Config, and the comment at the top of its file names them.
|
||||||
|
|
||||||
|
// heuristic is one of the checks that decide what discriminator does with a
|
||||||
|
// request.
|
||||||
|
type heuristic interface {
|
||||||
|
// name is the heuristic's name, which the log line gives.
|
||||||
|
name() string
|
||||||
|
// check looks at the request at now, and returns nil to let it through.
|
||||||
|
// ctx is the request's own context.
|
||||||
|
check(ctx context.Context, rq *request, now time.Time) *verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
// verdict is a heuristic refusing a request, or banning its client: the action
|
||||||
|
// the log line names, one of requestlog's Action constants, and the figures the
|
||||||
|
// heuristic acted on, by name, such as the count and the limit.
|
||||||
|
type verdict struct {
|
||||||
|
action string
|
||||||
|
figures map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// The names of the figures that more than one heuristic gives.
|
||||||
|
const (
|
||||||
|
figureWindow = "window"
|
||||||
|
figureCount = "count"
|
||||||
|
figureLimit = "limit"
|
||||||
|
figureScore = "score"
|
||||||
|
figureNetblock = "netblock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The lists of heuristics, each in the order its heuristics run. A client in
|
||||||
|
// SWW_ALLOW_NETS skips them all.
|
||||||
|
|
||||||
|
// beforeLookup are the heuristics that run before the client's AS number and
|
||||||
|
// country are looked up, so that a client they refuse is not looked up.
|
||||||
|
func beforeLookup() []heuristic {
|
||||||
|
return []heuristic{
|
||||||
|
tagLists{},
|
||||||
|
routeWatchTags{},
|
||||||
|
denyNets{},
|
||||||
|
activeBan{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterLookup are the heuristics that run once the lookup has been made.
|
||||||
|
// tag_deny runs twice: before the rate limits, on the tags known by then, and
|
||||||
|
// last, on the tag rule_files and core_rule_set can add.
|
||||||
|
func afterLookup() []heuristic {
|
||||||
|
return []heuristic{
|
||||||
|
countries{},
|
||||||
|
blocklists{},
|
||||||
|
crowdSec{},
|
||||||
|
dnsbl{},
|
||||||
|
abuseIPDB{},
|
||||||
|
tagDeny{},
|
||||||
|
rateLimits{},
|
||||||
|
trapPaths{},
|
||||||
|
ruleFiles{},
|
||||||
|
headerLimits{},
|
||||||
|
coreRuleSet{},
|
||||||
|
tagDeny{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterAnswer are the heuristics that run on a request once discriminator has
|
||||||
|
// answered it, before its decision line is written. They ban the client, and do
|
||||||
|
// not change what the request got: their verdict's action is not used.
|
||||||
|
func afterAnswer() []heuristic {
|
||||||
|
return []heuristic{
|
||||||
|
refusalLimit{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onReport are the heuristics that run on an allowed request when rproxy's
|
||||||
|
// report of it comes, before its report line is written. Like afterAnswer, they
|
||||||
|
// only ban.
|
||||||
|
func onReport() []heuristic {
|
||||||
|
return []heuristic{
|
||||||
|
byteLimits{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAfterAnswer runs the heuristics of afterAnswer on the request, at the time
|
||||||
|
// it is called.
|
||||||
|
func (rq *request) runAfterAnswer(ctx context.Context) {
|
||||||
|
rq.runBanningHeuristics(ctx, afterAnswer())
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHeuristics runs list, beforeLookup or afterLookup, on the request at now,
|
||||||
|
// and returns the first verdict, or nil when none gives one. The log line's
|
||||||
|
// heuristic and heuristic_figures name the heuristic that gave it, and give its
|
||||||
|
// figures. ctx is the request's own context.
|
||||||
|
func (rq *request) runHeuristics(
|
||||||
|
ctx context.Context, list []heuristic, now time.Time,
|
||||||
|
) *verdict {
|
||||||
|
name, v := rq.firstVerdict(ctx, list, now)
|
||||||
|
if v != nil {
|
||||||
|
rq.line.Heuristic = name
|
||||||
|
rq.line.HeuristicFigures = v.figures
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBanningHeuristics runs list, afterAnswer or onReport, on the request at
|
||||||
|
// the time it is called. The log line's after_answer_heuristic and
|
||||||
|
// after_answer_heuristic_figures name the heuristic that gave a verdict, and
|
||||||
|
// give its figures, so that heuristic and heuristic_figures still name the one
|
||||||
|
// that refused the request. ctx is the request's own context.
|
||||||
|
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
|
||||||
|
name, v := rq.firstVerdict(ctx, list, rq.h.now())
|
||||||
|
if v != nil {
|
||||||
|
rq.line.AfterAnswerHeuristic = name
|
||||||
|
rq.line.AfterAnswerHeuristicFigures = v.figures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstVerdict runs list on the request at now, in order, until a heuristic
|
||||||
|
// gives a verdict, and returns that heuristic's name and its verdict, or "" and
|
||||||
|
// nil when none gives one. ctx is the request's own context.
|
||||||
|
func (rq *request) firstVerdict(
|
||||||
|
ctx context.Context, list []heuristic, now time.Time,
|
||||||
|
) (string, *verdict) {
|
||||||
|
for _, each := range list {
|
||||||
|
v := each.check(ctx, rq, now)
|
||||||
|
if v != nil {
|
||||||
|
return each.name(), v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
||||||
@@ -20,7 +20,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -31,9 +31,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
clk.advance(time.Second)
|
clk.advance(time.Second)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
clk.advance(time.Second)
|
clk.advance(time.Second)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
want := ratelimit.History{
|
want := ratelimit.History{
|
||||||
FirstSeen: start,
|
FirstSeen: start,
|
||||||
@@ -45,7 +45,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
Requests: 4,
|
Requests: 4,
|
||||||
Forwarded: 2,
|
Forwarded: 2,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
// The app answers with no body, smallwebwaf with its status text.
|
// The app answers with no body, sww with its status text.
|
||||||
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
||||||
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
||||||
Offences: ratelimit.Offences{Limit: 1},
|
Offences: ratelimit.Offences{Limit: 1},
|
||||||
@@ -100,8 +100,9 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
|
|||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||||
|
|
||||||
s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK)
|
s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
|
||||||
s.adminRequest("", http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized)
|
s.ownEndpointRequest("", http.MethodGet, discriminator.MetricsPath, "",
|
||||||
|
http.StatusUnauthorized)
|
||||||
s.scrape()
|
s.scrape()
|
||||||
|
|
||||||
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
||||||
@@ -115,7 +116,7 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
|||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
|
||||||
out.endedLine(t)
|
out.endedLine(t)
|
||||||
|
|
||||||
history := historyOf(t, server, localhost)
|
history := historyOf(t, server, localhost)
|
||||||
@@ -126,7 +127,9 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// historyOf returns the history of the client at addr.
|
// historyOf returns the history of the client at addr.
|
||||||
func historyOf(t *testing.T, server *servewaf.Server, addr string) ratelimit.History {
|
func historyOf(
|
||||||
|
t *testing.T, server *discriminator.Server, addr string,
|
||||||
|
) ratelimit.History {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
client := netip.MustParsePrefix(addr + "/32")
|
client := netip.MustParsePrefix(addr + "/32")
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The headers in which an answer of allow gives rproxy the client's AS number
|
// The headers in which an answer of allow gives rproxy the client's AS number
|
||||||
// and country, for the app, while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes
|
// and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes
|
||||||
// every header name in this form, as it sends it and as it receives it, so
|
// every header name in this form, as it sends it and as it receives it, so
|
||||||
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
||||||
const (
|
const (
|
||||||
@@ -19,8 +21,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// lookUp looks up the client's AS number and country, in the lookup
|
// lookUp looks up the client's AS number and country, in the lookup
|
||||||
// database or through GeoJS, and notes them for the log line, unless
|
// database or through GeoJS, and notes them for the log line and as the
|
||||||
// SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
// request's asn= and country= tags, unless
|
||||||
|
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
||||||
// link-local address, which no lookup can place. The lookup database
|
// link-local address, which no lookup can place. The lookup database
|
||||||
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
||||||
// country list or a biased threshold, a new client's request waits for it,
|
// country list or a biased threshold, a new client's request waits for it,
|
||||||
@@ -47,6 +50,34 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
|
|||||||
rq.line.ASN = rq.lookupAnswer.ASN
|
rq.line.ASN = rq.lookupAnswer.ASN
|
||||||
rq.line.ASName = rq.lookupAnswer.ASName
|
rq.line.ASName = rq.lookupAnswer.ASName
|
||||||
rq.line.Country = rq.lookupAnswer.Country
|
rq.line.Country = rq.lookupAnswer.Country
|
||||||
|
|
||||||
|
if tag := asnTag(rq.line.ASN); tag != "" {
|
||||||
|
rq.addTag(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag := countryTag(rq.line.Country); tag != "" {
|
||||||
|
rq.addTag(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// asnTag returns the tag of asn, an AS number as the lookup gives it, such as
|
||||||
|
// asn=64496 for AS64496, or "" for no AS number.
|
||||||
|
func asnTag(asn string) string {
|
||||||
|
if asn == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.ASNTagPrefix + strings.TrimPrefix(asn, "AS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// countryTag returns the tag of country, a code as the lookup gives it, such
|
||||||
|
// as country=DE for DE, or "" for no country.
|
||||||
|
func countryTag(country string) string {
|
||||||
|
if country == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.CountryTagPrefix + country
|
||||||
}
|
}
|
||||||
|
|
||||||
// addLookup adds answer, an answer about a client from the lookup
|
// addLookup adds answer, an answer about a client from the lookup
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
// and country.
|
// and country.
|
||||||
type asnAndCountry struct{ asn, asName, country string }
|
type asnAndCountry struct{ asn, asName, country string }
|
||||||
|
|
||||||
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
|
||||||
// lookup database.
|
// lookup database.
|
||||||
const fileSource = "file"
|
const fileSource = "file"
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T)
|
|||||||
// comes too. None waits for GeoJS.
|
// comes too. None waits for GeoJS.
|
||||||
for _, line := range []logLine{
|
for _, line := range []logLine{
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
|
||||||
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||||
} {
|
} {
|
||||||
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
||||||
@@ -94,9 +94,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
|||||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
requestFromDE := func() *http.Request {
|
requestFromDE := func() *http.Request {
|
||||||
@@ -162,7 +162,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
|
|||||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
alertWebhookURL: "https://alerts.example/sww",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
s := &sender{t: t, addr: addr, out: out}
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
@@ -179,7 +179,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
|
|||||||
|
|
||||||
for _, line := range []logLine{
|
for _, line := range []logLine{
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
|
||||||
} {
|
} {
|
||||||
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
|
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
|
||||||
t.Errorf("log line has %+v, want %+v", got, want)
|
t.Errorf("log line has %+v, want %+v", got, want)
|
||||||
@@ -264,7 +264,7 @@ func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
|
|||||||
want asnAndCountry
|
want asnAndCountry
|
||||||
}{
|
}{
|
||||||
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
|
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
|
||||||
{s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), de},
|
{s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), de},
|
||||||
{
|
{
|
||||||
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||||
asnAndCountry{asnKP, asNameKP, "KP"},
|
asnAndCountry{asnKP, asNameKP, "KP"},
|
||||||
@@ -379,13 +379,13 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
|||||||
// clock of the test's own: the wait lasts exactly as long as it should,
|
// clock of the test's own: the wait lasts exactly as long as it should,
|
||||||
// however slowly the test process runs. Nothing in it may wait on the
|
// however slowly the test process runs. Nothing in it may wait on the
|
||||||
// network, which would keep that clock from moving on: the request is
|
// network, which would keep that clock from moving on: the request is
|
||||||
// handed to servewaf's handler, and GeoJS is one that never answers.
|
// handed to discriminator's handler, and GeoJS is one that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
// Not the default. The exclusive list needs the answer, and the app
|
// Not the default. The exclusive list needs the answer, and the app
|
||||||
// is never reached.
|
// is never reached.
|
||||||
const timeout = 3 * time.Second
|
const timeout = 3 * time.Second
|
||||||
|
|
||||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
lookupTimeout: timeout.String(),
|
lookupTimeout: timeout.String(),
|
||||||
allowedCountries: "DE",
|
allowedCountries: "DE",
|
||||||
@@ -405,25 +405,25 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// unansweredGeoJSURL is where a GeoJS that never answers is asked: a
|
// silentGeoJSURL is where a GeoJS that never answers is asked: a
|
||||||
// request to it waits, without the network, until it is abandoned.
|
// request to it waits, without the network, until it is abandoned.
|
||||||
// TestMain registers it with Go's default transport, through which GeoJS
|
// TestMain registers it with Go's default transport, through which GeoJS
|
||||||
// is asked.
|
// is asked.
|
||||||
const unansweredGeoJSURL = "unanswered://geojs/v1/ip/geo.json"
|
const silentGeoJSURL = "silent://geojs/v1/ip/geo.json"
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
transport, _ := http.DefaultTransport.(*http.Transport)
|
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||||
transport.RegisterProtocol("unanswered", unansweredGeoJS{})
|
transport.RegisterProtocol("silent", silentGeoJS{})
|
||||||
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
|
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
|
||||||
|
|
||||||
m.Run()
|
m.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// unansweredGeoJS is the GeoJS at unansweredGeoJSURL.
|
// silentGeoJS is the GeoJS at silentGeoJSURL.
|
||||||
type unansweredGeoJS struct{}
|
type silentGeoJS struct{}
|
||||||
|
|
||||||
// RoundTrip waits until req is abandoned.
|
// RoundTrip waits until req is abandoned.
|
||||||
func (unansweredGeoJS) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (silentGeoJS) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
<-req.Context().Done()
|
<-req.Context().Done()
|
||||||
|
|
||||||
return nil, req.Context().Err()
|
return nil, req.Context().Err()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -11,15 +11,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
metricsTopN = "SWW_METRICS_TOP_N"
|
||||||
// token is the SWWAF_METRICS_TOKEN the tests set, and bearer how a
|
// token is the SWW_METRICS_TOKEN the tests set, and bearer how a
|
||||||
// request carries it.
|
// request carries it.
|
||||||
token = "0123456789abcdef0123456789abcdef"
|
token = "0123456789abcdef0123456789abcdef"
|
||||||
bearer = "Bearer " + token
|
bearer = "Bearer " + token
|
||||||
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
|||||||
|
|
||||||
// An empty token does not match the unset one either.
|
// An empty token does not match the unset one either.
|
||||||
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
||||||
s.adminRequest(authorization, http.MethodGet, servewaf.MetricsPath, "",
|
s.ownEndpointRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
|
||||||
http.StatusNotFound)
|
http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,20 +46,21 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
|||||||
method, path, authorization string
|
method, path, authorization string
|
||||||
status int
|
status int
|
||||||
}{
|
}{
|
||||||
{http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized},
|
{http.MethodGet, discriminator.MetricsPath, "", http.StatusUnauthorized},
|
||||||
{
|
{
|
||||||
http.MethodGet, servewaf.MetricsPath, "Bearer " + strings.ToUpper(token),
|
http.MethodGet, discriminator.MetricsPath, "Bearer " + strings.ToUpper(token),
|
||||||
http.StatusUnauthorized,
|
http.StatusUnauthorized,
|
||||||
},
|
},
|
||||||
{http.MethodGet, servewaf.MetricsPath, "Basic " + token, http.StatusUnauthorized},
|
{http.MethodGet, discriminator.MetricsPath, "Basic " + token,
|
||||||
{http.MethodGet, servewaf.MetricsPath, bearer, http.StatusOK},
|
http.StatusUnauthorized},
|
||||||
{http.MethodGet, servewaf.MetricsPath, "bearer " + token, http.StatusOK},
|
{http.MethodGet, discriminator.MetricsPath, bearer, http.StatusOK},
|
||||||
{http.MethodPost, servewaf.MetricsPath, bearer, http.StatusNotFound},
|
{http.MethodGet, discriminator.MetricsPath, "bearer " + token, http.StatusOK},
|
||||||
{http.MethodGet, servewaf.MetricsPath + "/", bearer, http.StatusNotFound},
|
{http.MethodPost, discriminator.MetricsPath, bearer, http.StatusNotFound},
|
||||||
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound},
|
{http.MethodGet, discriminator.MetricsPath + "/", bearer, http.StatusNotFound},
|
||||||
{http.MethodPost, servewaf.HealthPath, "", http.StatusNotFound},
|
{http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
|
||||||
|
{http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound},
|
||||||
} {
|
} {
|
||||||
got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status)
|
got := s.ownEndpointRequest(tc.authorization, tc.method, tc.path, "", tc.status)
|
||||||
|
|
||||||
if tc.status == http.StatusUnauthorized &&
|
if tc.status == http.StatusUnauthorized &&
|
||||||
got.header.Get("WWW-Authenticate") != "Bearer" {
|
got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||||
@@ -68,7 +69,7 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if tc.status == http.StatusOK &&
|
if tc.status == http.StatusOK &&
|
||||||
!strings.Contains(string(got.body), "# TYPE smallwebwaf_requests_total counter") {
|
!strings.Contains(string(got.body), "# TYPE sww_requests_total counter") {
|
||||||
t.Errorf("the metrics are\n%s", got.body)
|
t.Errorf("the metrics are\n%s", got.body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,25 +87,25 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
|
|
||||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||||
wantStatus(t, got, http.StatusOK)
|
wantStatus(t, got, http.StatusOK)
|
||||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
|
||||||
out.endedLines(t, 2)
|
out.endedLines(t, 2)
|
||||||
|
|
||||||
forward := `{action="forward",instance="app",status_class="2xx"}`
|
forward := `{action="forward",instance="app",status_class="2xx"}`
|
||||||
notFound := `{action="admin",instance="app",status_class="4xx"}`
|
notFound := `{action="own_endpoint",instance="app",status_class="4xx"}`
|
||||||
|
|
||||||
// The request for the metrics is itself under way.
|
// The request for the metrics is itself under way.
|
||||||
metrics := scrape(t, server.Addr)
|
metrics := scrape(t, server.Addr)
|
||||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1)
|
wantMetric(t, metrics, "sww_requests_total"+forward, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1)
|
wantMetric(t, metrics, "sww_requests_total"+notFound, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3)
|
wantMetric(t, metrics, "sww_request_bytes_total"+forward, 3)
|
||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
|
wantMetric(t, metrics, "sww_response_bytes_total"+forward, 5)
|
||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
|
wantMetric(t, metrics, "sww_response_bytes_total"+notFound,
|
||||||
float64(len("Not Found\n")))
|
float64(len("Not Found\n")))
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_request_duration_seconds_count{instance="app"}`, 2)
|
`sww_request_duration_seconds_count{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1)
|
`sww_upstream_duration_seconds_count{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_requests_in_flight{instance="app"}`, 1)
|
||||||
metric(t, metrics, `go_goroutines{instance="app"}`)
|
metric(t, metrics, `go_goroutines{instance="app"}`)
|
||||||
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
@@ -123,40 +124,40 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
maxBanDuration: "2h",
|
maxBanDuration: "2h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// SWWAF_BAN_RESPONSE=close sends no status at all.
|
// SWW_BAN_RESPONSE=close sends no status at all.
|
||||||
s.get(denied, 0, requestlog.ActionDenied)
|
s.get(denied, 0, requestlog.ActionClientDenied)
|
||||||
|
|
||||||
// A first broken limit bans for an hour.
|
// A first broken limit bans for an hour.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, 0, requestlog.ActionRateLimited)
|
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
`sww_requests_total{action="client_denied",instance="app",status_class="none"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="requests",window="minute"}`, 1)
|
`kind="requests",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0)
|
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
wantMetric(t, s.scrape(), `smallwebwaf_active_bans{instance="app"}`, 0)
|
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
// A limit broken again right after would ban for three hours, longer
|
// A limit broken again right after would ban for three hours, longer
|
||||||
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
// than SWW_MAX_BAN_DURATION, so the ban is permanent.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, 0, requestlog.ActionRateLimited)
|
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
metrics = s.scrape()
|
metrics = s.scrape()
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="requests",window="minute"}`, 2)
|
`kind="requests",window="minute"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
|
||||||
// denied and client.
|
// denied and client.
|
||||||
wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 2)
|
wantMetric(t, metrics, `sww_tracked_clients{instance="app"}`, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
||||||
@@ -167,7 +168,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
|||||||
adminToken: adminSecret,
|
adminToken: adminSecret,
|
||||||
})
|
})
|
||||||
|
|
||||||
const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}`
|
const admins = `sww_bans_made_total{cause="admin",instance="app"}`
|
||||||
|
|
||||||
wantMetric(t, s.scrape(), admins, 0)
|
wantMetric(t, s.scrape(), admins, 0)
|
||||||
|
|
||||||
@@ -180,7 +181,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
|||||||
wantMetric(t, s.scrape(), admins, 1)
|
wantMetric(t, s.scrape(), admins, 1)
|
||||||
|
|
||||||
// And a ban made through the endpoint.
|
// And a ban made through the endpoint.
|
||||||
s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK)
|
s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, http.StatusOK)
|
||||||
wantMetric(t, s.scrape(), admins, 2)
|
wantMetric(t, s.scrape(), admins, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,10 +195,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
_, _ = io.WriteString(w, "hello")
|
_, _ = io.WriteString(w, "hello")
|
||||||
})
|
})
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "2",
|
metricsTopN: "2",
|
||||||
deniedCountries: "kp",
|
allowedCountries: "de,fr",
|
||||||
}
|
}
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
||||||
@@ -233,25 +234,25 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
lines++
|
lines++
|
||||||
|
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
`sww_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2)
|
`sww_country_requests_total{country="DE",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 1)
|
`sww_country_requests_total{country="other",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_list_refusals_total{country="KP",instance="app"}`, 3)
|
`sww_country_list_refusals_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_request_bytes_total{country="KP",instance="app"}`, 0)
|
`sww_country_request_bytes_total{country="KP",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`, 6)
|
`sww_country_request_bytes_total{country="DE",instance="app"}`, 6)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_response_bytes_total{country="KP",instance="app"}`,
|
`sww_country_response_bytes_total{country="KP",instance="app"}`,
|
||||||
float64(3*len("Forbidden\n")))
|
float64(3*len("Forbidden\n")))
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_response_bytes_total{country="other",instance="app"}`,
|
`sww_country_response_bytes_total{country="other",instance="app"}`,
|
||||||
float64(len("hello")))
|
float64(len("hello")))
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="FR",instance="app"}`)
|
`sww_country_requests_total{country="FR",instance="app"}`)
|
||||||
|
|
||||||
// Once FR is busier than DE, it takes DE's place: its series counts
|
// Once FR is busier than DE, it takes DE's place: its series counts
|
||||||
// from then on, and DE's is gone.
|
// from then on, and DE's is gone.
|
||||||
@@ -259,15 +260,15 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
|
|
||||||
metrics = scrape(t, server.Addr)
|
metrics = scrape(t, server.Addr)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
`sww_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2)
|
`sww_country_requests_total{country="FR",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 2)
|
`sww_country_requests_total{country="other",instance="app"}`, 2)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="DE",instance="app"}`)
|
`sww_country_requests_total{country="DE",instance="app"}`)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`)
|
`sww_country_request_bytes_total{country="DE",instance="app"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||||
@@ -298,15 +299,15 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
`sww_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_asn_requests_total{asn="other",instance="app"}`, 1)
|
`sww_asn_requests_total{asn="other",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
|
`sww_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_asn_response_bytes_total{asn="other",instance="app"}`, 0)
|
`sww_asn_response_bytes_total{asn="other",instance="app"}`, 0)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_asn_requests_total{asn="AS64502",instance="app"}`)
|
`sww_asn_requests_total{asn="AS64502",instance="app"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||||
@@ -321,32 +322,32 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
|||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
|
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
// GeoJS fails, so the client counts as coming from an unknown country,
|
// GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does
|
||||||
// which SWWAF_DENIED_COUNTRIES does not refuse.
|
// not refuse it.
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
req.Header.Set(forwardedFor, fromDE)
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
// The client stops waiting for GeoJS once SWWAF_LOOKUP_TIMEOUT is up, so
|
// The client stops waiting for GeoJS once SWW_LOOKUP_TIMEOUT is up, so
|
||||||
// GeoJS's failure can come after its request has ended.
|
// GeoJS's failure can come after its request has ended.
|
||||||
deadline := time.Now().Add(waitLimit)
|
deadline := time.Now().Add(waitLimit)
|
||||||
metrics := scrape(t, server.Addr)
|
metrics := scrape(t, server.Addr)
|
||||||
|
|
||||||
for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 &&
|
for metric(t, metrics, `sww_geojs_failures_total{instance="app"}`) == 0 &&
|
||||||
time.Now().Before(deadline) {
|
time.Now().Before(deadline) {
|
||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
|
|
||||||
metrics = scrape(t, server.Addr)
|
metrics = scrape(t, server.Addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_total{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_requests_without_geojs_answer_total{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// keptAnswer returns GeoJS's answer that the client at addr is in
|
// keptAnswer returns GeoJS's answer that the client at addr is in
|
||||||
@@ -360,12 +361,12 @@ func keptAnswer(addr, country string) lookup.Answer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// scrape asks servewaf at addr for the metrics, with the token, and returns
|
// scrape asks discriminator at addr for the metrics, with the token, and returns
|
||||||
// them.
|
// them.
|
||||||
func scrape(t *testing.T, addr string) string {
|
func scrape(t *testing.T, addr string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := newRequest(t, http.MethodGet, addr, servewaf.MetricsPath, http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, discriminator.MetricsPath, http.NoBody)
|
||||||
req.Header.Set("Authorization", bearer)
|
req.Header.Set("Authorization", bearer)
|
||||||
|
|
||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
@@ -380,14 +381,14 @@ func scrape(t *testing.T, addr string) string {
|
|||||||
func (s *sender) scrape() string {
|
func (s *sender) scrape() string {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
return string(s.adminRequest(bearer, http.MethodGet, servewaf.MetricsPath, "",
|
return string(s.ownEndpointRequest(bearer, http.MethodGet, discriminator.MetricsPath,
|
||||||
http.StatusOK).body)
|
"", http.StatusOK).body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// metric returns the value of series in metrics, which are in the
|
// metric returns the value of series in metrics, which are in the
|
||||||
// Prometheus text format. series is a name and its labels in the order of
|
// Prometheus text format. series is a name and its labels in the order of
|
||||||
// their names, such as
|
// their names, such as
|
||||||
// smallwebwaf_offences_total{instance="app",kind="limit"}. It fails the
|
// sww_offences_total{instance="app",kind="limit"}. It fails the
|
||||||
// test if there is no such series.
|
// test if there is no such series.
|
||||||
func metric(t *testing.T, metrics, series string) float64 {
|
func metric(t *testing.T, metrics, series string) float64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -7,23 +7,123 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// observe is the value of SWWAF_MODE for observe mode.
|
// observe is the value of SWW_MODE for observe mode.
|
||||||
const observe = "observe"
|
const observe = "observe"
|
||||||
|
|
||||||
|
func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const trapPath = "/wp-login.php"
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cause string
|
||||||
|
// endsIn is how long after the request the ban ends: negative for a
|
||||||
|
// ban that has ended, and zero for a permanent ban.
|
||||||
|
endsIn time.Duration
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"temporary ban", bans.CauseAdmin, time.Hour, "/",
|
||||||
|
requestlog.ActionTemporarilyBanned,
|
||||||
|
},
|
||||||
|
{"permanent ban", bans.CauseAdmin, 0, "/", requestlog.ActionPermanentlyBanned},
|
||||||
|
{
|
||||||
|
"request that makes a ban permanent", bans.CauseAttack, time.Hour, "/",
|
||||||
|
requestlog.ActionPermanentlyBanned,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"trap path after an attack ban ended", bans.CauseAttack, -time.Hour,
|
||||||
|
trapPath, requestlog.ActionPermanentlyBanned,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"first trap path after another ban ended", bans.CauseAdmin, -time.Hour,
|
||||||
|
trapPath, requestlog.ActionTemporarilyBanned,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
for _, modeValue := range []string{"enforce", observe} {
|
||||||
|
t.Run(tc.name+" in "+modeValue, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
|
trapPaths: trapPathList, mode: modeValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
ban := bans.Ban{
|
||||||
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||||
|
Start: clk.Now().Add(-2 * time.Hour),
|
||||||
|
Cause: tc.cause,
|
||||||
|
}
|
||||||
|
if tc.endsIn != 0 {
|
||||||
|
ban.Expires = clk.Now().Add(tc.endsIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
server.Ledger.Load([]bans.Ban{ban})
|
||||||
|
|
||||||
|
if modeValue == observe {
|
||||||
|
line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, tc.want)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.request(client, tc.path, http.StatusForbidden, tc.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||||
|
trapPaths: trapPathList, mode: observe,
|
||||||
|
})
|
||||||
|
|
||||||
|
server.Ledger.Load([]bans.Ban{{
|
||||||
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||||
|
Start: clk.Now().Add(-2 * time.Hour),
|
||||||
|
Expires: clk.Now().Add(-time.Hour),
|
||||||
|
Cause: bans.CauseAttack,
|
||||||
|
}})
|
||||||
|
|
||||||
|
line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionPermanentlyBanned)
|
||||||
|
|
||||||
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventPermanentBan {
|
||||||
|
t.Errorf("alerts waiting are %+v, want one %s", waiting, alerts.EventPermanentBan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
mode: observe, denyNets: client,
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
||||||
|
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
banned = otherClient // under a ban read from bans.json
|
banned = otherClient // under a ban read from bans.json
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWWAF_MODE at its default
|
setting string // "" leaves SWW_MODE at its default
|
||||||
observe bool
|
observe bool
|
||||||
}{
|
}{
|
||||||
{"", false},
|
{"", false},
|
||||||
@@ -38,7 +138,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -57,10 +157,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
for _, sent := range []struct{ from, refusal string }{
|
for _, sent := range []struct{ from, refusal string }{
|
||||||
{denied, requestlog.ActionDenied},
|
{denied, requestlog.ActionClientDenied},
|
||||||
{banned, requestlog.ActionBanned},
|
{banned, requestlog.ActionTemporarilyBanned},
|
||||||
{fromKP, requestlog.ActionCountryDenied},
|
{fromKP, requestlog.ActionClientDenied},
|
||||||
{fromDE, requestlog.ActionRateLimited},
|
{fromDE, requestlog.ActionClientIsRateLimited},
|
||||||
} {
|
} {
|
||||||
if !tc.observe {
|
if !tc.observe {
|
||||||
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
|
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
|
||||||
@@ -98,7 +198,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
|||||||
|
|
||||||
for range 2 {
|
for range 2 {
|
||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||||
line.BanExpires != "" {
|
line.BanExpires != "" {
|
||||||
@@ -111,7 +211,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
|||||||
// The ban read from bans.json refuses nothing, and so counts no
|
// The ban read from bans.json refuses nothing, and so counts no
|
||||||
// refusal in its notes, but is kept.
|
// refusal in its notes, but is kept.
|
||||||
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
|
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
|
||||||
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
|
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package servewaf is the server the servewaf command runs. It answers rproxy's
|
// Package discriminator is the server the discriminator command runs. It answers
|
||||||
// questions, takes rproxy's reports and answers its own endpoints, and writes a
|
// rproxy's questions, takes rproxy's reports and answers its own endpoints, and
|
||||||
// log line for each.
|
// writes a log line for each.
|
||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -16,54 +16,56 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminPrefix starts the path of each of servewaf's own endpoints. The app never
|
// ownEndpointPrefix starts the path of each of discriminator's own endpoints. The app
|
||||||
// sees a path under it.
|
// never sees a path under it.
|
||||||
const adminPrefix = "/_smallwebwaf/"
|
const ownEndpointPrefix = "/_sww/"
|
||||||
|
|
||||||
// HealthPath is the health endpoint of servewaf and of rproxy, which the
|
// HealthPath is the health endpoint of discriminator and of rproxy, which the
|
||||||
// container's health check asks.
|
// container's health check asks.
|
||||||
const HealthPath = "/_smallwebwaf/healthz"
|
const HealthPath = "/_sww/healthz"
|
||||||
|
|
||||||
// MetricsPath is where the metrics are, for a request that carries
|
// MetricsPath is where the metrics are, for a request that carries
|
||||||
// SWWAF_METRICS_TOKEN.
|
// SWW_METRICS_TOKEN.
|
||||||
const MetricsPath = "/_smallwebwaf/metrics"
|
const MetricsPath = "/_sww/metrics"
|
||||||
|
|
||||||
// BansPath is where an admin lists and adds bans, and, followed by / and
|
// BansPath is where an admin lists and adds bans, and, followed by / and
|
||||||
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN.
|
// a client's address, lifts them, with SWW_ADMIN_TOKEN.
|
||||||
const BansPath = "/_smallwebwaf/bans"
|
const BansPath = "/_sww/bans"
|
||||||
|
|
||||||
// ClientsPath is where an admin asks what servewaf knows of a client, by the
|
// ClientsPath is where an admin asks what discriminator knows of a client, by
|
||||||
// client's address after it, with SWWAF_ADMIN_TOKEN.
|
// the client's address after it, with SWW_ADMIN_TOKEN.
|
||||||
const ClientsPath = "/_smallwebwaf/clients/"
|
const ClientsPath = "/_sww/clients/"
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
// RequestLog receives two JSON lines per question, one at its decision and
|
// RequestLog receives two JSON lines per question, one at its decision and
|
||||||
// one when its report comes, and one per request to servewaf's own endpoints.
|
// one when its report comes, and one per request to discriminator's own
|
||||||
|
// endpoints.
|
||||||
RequestLog io.Writer
|
RequestLog io.Writer
|
||||||
// ProcessLog receives the process's own messages.
|
// ProcessLog receives the process's own messages.
|
||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
// GeoJSURL is where clients' AS numbers and countries are looked up
|
// GeoJSURL is where clients' AS numbers and countries are looked up
|
||||||
// while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
// while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
||||||
GeoJSURL string
|
GeoJSURL string
|
||||||
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
|
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
|
||||||
// SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
|
// SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
|
||||||
AbuseIPDBURL string
|
AbuseIPDBURL string
|
||||||
// LookupFile is the lookup database they are looked up in while
|
// LookupFile is the lookup database they are looked up in while
|
||||||
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
|
// SWW_LOOKUP_SOURCE is file, and nil otherwise.
|
||||||
LookupFile *lookup.File
|
LookupFile *lookup.File
|
||||||
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
|
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
|
||||||
// while SWWAF_WAF_MODE is not off, and nil while it is.
|
// while SWW_WAF_MODE is not off, and nil while it is.
|
||||||
CoreRuleSet *waf.CoreRuleSet
|
CoreRuleSet *coreruleset.CoreRuleSet
|
||||||
// Now tells the time by which requests are counted for the rate
|
// Now tells the time by which requests are counted for the rate
|
||||||
// limits, bans are made and run out, and GeoJS's answers are kept,
|
// limits, bans are made and run out, and GeoJS's answers are kept,
|
||||||
// normally time.Now in UTC, the time the state files give.
|
// normally time.Now in UTC, the time the state files give.
|
||||||
@@ -71,20 +73,21 @@ type Params struct {
|
|||||||
// Rules are the rule files' rules, which each request is checked
|
// Rules are the rule files' rules, which each request is checked
|
||||||
// against.
|
// against.
|
||||||
Rules *rules.Files
|
Rules *rules.Files
|
||||||
// Alerts receive the alert for each ban servewaf makes or makes
|
// Alerts receive the alert for each ban discriminator makes or makes
|
||||||
// permanent, for each count over an anomaly threshold, for each request
|
// permanent, for each count over an anomaly threshold, for each request
|
||||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
||||||
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
// SWW_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
||||||
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
||||||
// or the day's AbuseIPDB checks used up.
|
// or the day's AbuseIPDB checks used up.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the server servewaf runs, with the parts whose state the state
|
// Server is the server discriminator runs, with the parts whose state the state
|
||||||
// files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the
|
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
|
||||||
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
||||||
// AbuseIPDB's scores and checks spent, and the metrics.
|
// AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while
|
||||||
|
// SWW_ROUTEWATCH_URL is unset, keeps its answers in memory only.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
*http.Server
|
*http.Server
|
||||||
|
|
||||||
@@ -96,6 +99,7 @@ type Server struct {
|
|||||||
Lists *reputation.Lists
|
Lists *reputation.Lists
|
||||||
DNSBL *reputation.DNSBL
|
DNSBL *reputation.DNSBL
|
||||||
AbuseIPDB *reputation.AbuseIPDB
|
AbuseIPDB *reputation.AbuseIPDB
|
||||||
|
RouteWatch *routewatch.Client
|
||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +129,7 @@ func newHandler(params Params) *handler {
|
|||||||
BytesPerHour: params.Config.BytesLimitPerHour,
|
BytesPerHour: params.Config.BytesLimitPerHour,
|
||||||
BytesPerDay: params.Config.BytesLimitPerDay,
|
BytesPerDay: params.Config.BytesLimitPerDay,
|
||||||
}, params.Config.MaxTrackedClients),
|
}, params.Config.MaxTrackedClients),
|
||||||
ledger: bans.New(bans.Rules{
|
ledger: bans.New(bans.Params{
|
||||||
LimitBanDuration: params.Config.LimitBanDuration,
|
LimitBanDuration: params.Config.LimitBanDuration,
|
||||||
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
||||||
MaxBanDuration: params.Config.MaxBanDuration,
|
MaxBanDuration: params.Config.MaxBanDuration,
|
||||||
@@ -151,14 +155,21 @@ func newHandler(params Params) *handler {
|
|||||||
coreRuleSet: params.CoreRuleSet,
|
coreRuleSet: params.CoreRuleSet,
|
||||||
alerts: params.Alerts,
|
alerts: params.Alerts,
|
||||||
}
|
}
|
||||||
|
if params.Config.RouteWatchURL != "" {
|
||||||
|
h.routeWatch = routewatch.New(routewatch.Params{
|
||||||
|
URL: params.Config.RouteWatchURL, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
h.geojs = lookup.New(lookup.Params{
|
h.geojs = lookup.New(lookup.Params{
|
||||||
URL: params.GeoJSURL,
|
URL: params.GeoJSURL,
|
||||||
Timeout: params.Config.LookupTimeout,
|
Timeout: params.Config.LookupTimeout,
|
||||||
// The country lists, the headers and the biased thresholds act on
|
// The country list, the headers, the tag settings that name an asn=
|
||||||
// the answer before the request goes on.
|
// or country= tag and the biased thresholds act on the answer before
|
||||||
Wait: len(params.Config.DeniedCountries) > 0 ||
|
// the request goes on.
|
||||||
len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
Wait: len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
||||||
params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config),
|
params.Config.AddLookupHeaders || params.Config.TagsNeedLookup() ||
|
||||||
|
biasedThresholdsSet(params.Config),
|
||||||
Answered: h.addLookup,
|
Answered: h.addLookup,
|
||||||
Now: params.Now,
|
Now: params.Now,
|
||||||
ProcessLog: params.ProcessLog,
|
ProcessLog: params.ProcessLog,
|
||||||
@@ -174,7 +185,7 @@ func newHandler(params Params) *handler {
|
|||||||
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
||||||
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
||||||
// with none fetched, asked for or checked yet, and adds their metrics to
|
// with none fetched, asked for or checked yet, and adds their metrics to
|
||||||
// m, AbuseIPDB's while SWWAF_ABUSEIPDB_KEY is set.
|
// m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set.
|
||||||
func newReputation(
|
func newReputation(
|
||||||
params Params, m *metrics.Metrics,
|
params Params, m *metrics.Metrics,
|
||||||
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
|
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
|
||||||
@@ -182,6 +193,7 @@ func newReputation(
|
|||||||
lists := reputation.New(reputation.Params{
|
lists := reputation.New(reputation.Params{
|
||||||
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
||||||
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
|
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
|
||||||
|
TagLists: cfg.TagLists,
|
||||||
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
|
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
|
||||||
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
||||||
})
|
})
|
||||||
@@ -206,28 +218,27 @@ func newReputation(
|
|||||||
return lists, dnsbl, abuseIPDB
|
return lists, dnsbl, abuseIPDB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL,
|
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||||
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
|
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||||
// up to SWWAF_WAF_BODY_LIMIT, with the exclusions in
|
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||||
// SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWWAF_WAF_MODE is off. It
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
|
||||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||||
// JSON or XML body over its limit. An exclusions file that cannot be read, does
|
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
||||||
// not parse or names an id of smallwebwaf's own rules is an error in every
|
// an error in every mode. While the mode is off, a set file is loaded with the
|
||||||
// mode. While the mode is off, a set file is loaded with the Core Rule Set only
|
// Core Rule Set only to check it.
|
||||||
// to check it.
|
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
||||||
func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
|
|
||||||
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
|
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
|
||||||
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
||||||
}
|
}
|
||||||
|
|
||||||
coreRuleSet, err := waf.New(waf.Params{
|
coreRuleSet, err := coreruleset.New(coreruleset.Params{
|
||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.WAFMode == config.WAFModeOff {
|
if cfg.WAFMode == config.WAFModeOff {
|
||||||
@@ -237,7 +248,7 @@ func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
|
|||||||
return coreRuleSet, nil
|
return coreRuleSet, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handler is servewaf's handler. It holds what every request shares; what
|
// handler is discriminator's handler. It holds what every request shares; what
|
||||||
// belongs to one request is in a request.
|
// belongs to one request is in a request.
|
||||||
type handler struct {
|
type handler struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
@@ -253,8 +264,9 @@ type handler struct {
|
|||||||
lists *reputation.Lists
|
lists *reputation.Lists
|
||||||
dnsbl *reputation.DNSBL
|
dnsbl *reputation.DNSBL
|
||||||
abuseIPDB *reputation.AbuseIPDB
|
abuseIPDB *reputation.AbuseIPDB
|
||||||
|
routeWatch *routewatch.Client
|
||||||
rules *rules.Files
|
rules *rules.Files
|
||||||
coreRuleSet *waf.CoreRuleSet
|
coreRuleSet *coreruleset.CoreRuleSet
|
||||||
alerts *alerts.Queue
|
alerts *alerts.Queue
|
||||||
// awaitingReport holds the decision on each answered question by its id
|
// awaitingReport holds the decision on each answered question by its id
|
||||||
// until its report comes, and nil for a question still being handled.
|
// until its report comes, and nil for a question still being handled.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -17,11 +17,11 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -55,33 +55,35 @@ const (
|
|||||||
|
|
||||||
// The settings the tests set.
|
// The settings the tests set.
|
||||||
const (
|
const (
|
||||||
mode = "SWWAF_MODE"
|
mode = "SWW_MODE"
|
||||||
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
||||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||||
allowNets = "SWWAF_ALLOW_NETS"
|
allowNets = "SWW_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWWAF_DENY_NETS"
|
denyNets = "SWW_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
tagDeny = "SWW_TAG_DENY"
|
||||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
|
||||||
banResponse = "SWWAF_BAN_RESPONSE"
|
denyKP = "country=KP"
|
||||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
maxBans = "SWWAF_MAX_BANS"
|
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||||
instanceName = "SWWAF_INSTANCE_NAME"
|
maxBans = "SWW_MAX_BANS"
|
||||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
rulesDir = "SWWAF_RULES_DIR"
|
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||||
wafMode = "SWWAF_WAF_MODE"
|
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||||
|
rulesDir = "SWW_RULES_DIR"
|
||||||
|
wafMode = "SWW_WAF_MODE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// off is the value that switches a setting off.
|
// off is the value that switches a setting off.
|
||||||
@@ -90,13 +92,13 @@ const off = "off"
|
|||||||
// on is the value that switches a setting that takes true or false on.
|
// on is the value that switches a setting that takes true or false on.
|
||||||
const on = "true"
|
const on = "true"
|
||||||
|
|
||||||
// output collects what smallwebwaf writes on stdout.
|
// output collects what sww writes on stdout.
|
||||||
type output struct {
|
type output struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buf bytes.Buffer
|
buf bytes.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write adds lines smallwebwaf writes.
|
// Write adds lines sww writes.
|
||||||
func (o *output) Write(p []byte) (int, error) {
|
func (o *output) Write(p []byte) (int, error) {
|
||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
defer o.mu.Unlock()
|
defer o.mu.Unlock()
|
||||||
@@ -175,9 +177,9 @@ func (o *output) requestLine(t *testing.T) logLine {
|
|||||||
return o.requestLines(t, 1)[0]
|
return o.requestLines(t, 1)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// endedLines waits for count requests that went through rproxy, or to servewaf's
|
// endedLines waits for count requests that went through rproxy, or to discriminator's
|
||||||
// own endpoints, to have ended, and returns the last log line of each, in the
|
// own endpoints, to have ended, and returns the last log line of each, in the
|
||||||
// order servewaf took them. A question's last line is the one its report writes.
|
// order discriminator took them. A question's last line is the one its report writes.
|
||||||
func (o *output) endedLines(t *testing.T, count int) []logLine {
|
func (o *output) endedLines(t *testing.T, count int) []logLine {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -187,7 +189,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
|
|||||||
|
|
||||||
ended := len(found) >= count
|
ended := len(found) >= count
|
||||||
for _, line := range found[:min(count, len(found))] {
|
for _, line := range found[:min(count, len(found))] {
|
||||||
if line.ServeWAFLine == requestlog.ServeWAFLineDecision {
|
if line.DiscriminatorLine == requestlog.DiscriminatorLineDecision {
|
||||||
ended = false
|
ended = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +207,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// lastLines returns the last request log line written so far of each request,
|
// lastLines returns the last request log line written so far of each request,
|
||||||
// in the order servewaf took them.
|
// in the order discriminator took them.
|
||||||
func (o *output) lastLines(t *testing.T) []logLine {
|
func (o *output) lastLines(t *testing.T) []logLine {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -271,9 +273,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
// startProxy starts servewaf, and rproxy in front of the app at appURL, each with
|
// startProxy starts discriminator, and rproxy in front of the app at appURL, each with
|
||||||
// the settings in env on top of the defaults, and returns where rproxy listens
|
// the settings in env on top of the defaults, and returns where rproxy listens
|
||||||
// and what servewaf writes.
|
// and what discriminator writes.
|
||||||
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -293,14 +295,14 @@ func startProxyWithGeoJS(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||||
// bans made by the time now tells, and returns servewaf's server as well, whose
|
// bans made by the time now tells, and returns discriminator's server as well, whose
|
||||||
// Addr is where servewaf listens. Unless env sets SWWAF_RULES_DIR, it is an empty
|
// Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty
|
||||||
// directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app,
|
// directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app,
|
||||||
// the label instance of every metric.
|
// the label instance of every metric.
|
||||||
func startProxyWithClock(
|
func startProxyWithClock(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
) (string, *output, *servewaf.Server) {
|
) (string, *output, *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
||||||
@@ -309,14 +311,14 @@ func startProxyWithClock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
||||||
// the alerts servewaf raises as well, as newServeWAF makes them.
|
// the alerts discriminator raises as well, as newDiscriminator makes them.
|
||||||
func startProxyWithAlerts(
|
func startProxyWithAlerts(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
) (string, *output, *servewaf.Server, *alerts.Queue) {
|
) (string, *output, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
server, out, alertQueue := newServeWAF(t, geojsURL, now, env)
|
server, out, alertQueue := newDiscriminator(t, geojsURL, now, env)
|
||||||
serve(t, server)
|
serve(t, server)
|
||||||
|
|
||||||
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
|
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
|
||||||
@@ -324,7 +326,7 @@ func startProxyWithAlerts(
|
|||||||
|
|
||||||
// serve serves server on a port of its own until the test ends, and sets its
|
// serve serves server on a port of its own until the test ends, and sets its
|
||||||
// Addr to where it listens.
|
// Addr to where it listens.
|
||||||
func serve(t *testing.T, server *servewaf.Server) {
|
func serve(t *testing.T, server *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
@@ -343,17 +345,17 @@ func serve(t *testing.T, server *servewaf.Server) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRProxy starts rproxy in front of the app at appURL, asking the servewaf
|
// startRProxy starts rproxy in front of the app at appURL, asking the discriminator
|
||||||
// at serveWAFAddr, with the settings in env on top of the defaults, and returns
|
// at discriminatorAddr, with the settings in env on top of the defaults, and returns
|
||||||
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
|
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
|
||||||
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
|
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
|
||||||
func startRProxy(
|
func startRProxy(
|
||||||
t *testing.T, serveWAFAddr, appURL string, env map[string]string,
|
t *testing.T, discriminatorAddr, appURL string, env map[string]string,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWRP_WAF_BACKEND": "http://" + serveWAFAddr,
|
"SWRP_WAF_BACKEND": "http://" + discriminatorAddr,
|
||||||
"SWRP_UPSTREAM_URL": appURL,
|
"SWRP_UPSTREAM_URL": appURL,
|
||||||
"SWRP_TIMEOUT_MS": "60000",
|
"SWRP_TIMEOUT_MS": "60000",
|
||||||
}
|
}
|
||||||
@@ -390,28 +392,29 @@ func startRProxy(
|
|||||||
return listener.Addr().String()
|
return listener.Addr().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServeWAF makes servewaf's server, without starting it, and returns it, what
|
// newDiscriminator makes discriminator's server, without starting it, and
|
||||||
// it writes, and the queue of the alerts it raises, as the settings in env make
|
// returns it, what it writes, and the queue of the alerts it raises, as the
|
||||||
// it. No alert is sent from the queue: they wait in it, for the test to look at.
|
// settings in env make it. No alert is sent from the queue: they wait in it,
|
||||||
|
// for the test to look at.
|
||||||
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
||||||
// SWWAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
// SWW_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
||||||
// database SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
// database SWW_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||||
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless
|
// at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless
|
||||||
// env sets it, so that only the tests of the Core Rule Set have their requests
|
// env sets it, so that only the tests of the Core Rule Set have their requests
|
||||||
// inspected by it.
|
// inspected by it.
|
||||||
func newServeWAF(
|
func newDiscriminator(
|
||||||
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
||||||
) (*servewaf.Server, *output, *alerts.Queue) {
|
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return newServeWAFWith(t, servewaf.New, geojsURL, now, env)
|
return newDiscriminatorWith(t, discriminator.New, geojsURL, now, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServeWAFWith is newServeWAF for the server build makes.
|
// newDiscriminatorWith is newDiscriminator for the server build makes.
|
||||||
func newServeWAFWith(
|
func newDiscriminatorWith(
|
||||||
t *testing.T, build func(servewaf.Params) *servewaf.Server,
|
t *testing.T, build func(discriminator.Params) *discriminator.Server,
|
||||||
geojsURL string, now func() time.Time, env map[string]string,
|
geojsURL string, now func() time.Time, env map[string]string,
|
||||||
) (*servewaf.Server, *output, *alerts.Queue) {
|
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
@@ -463,12 +466,12 @@ func newServeWAFWith(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
coreRuleSet, err := servewaf.NewCoreRuleSet(cfg)
|
coreRuleSet, err := discriminator.NewCoreRuleSet(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Core Rule Set: %v", err)
|
t.Fatalf("Core Rule Set: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := build(servewaf.Params{
|
server := build(discriminator.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: out,
|
RequestLog: out,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
@@ -513,7 +516,7 @@ func readAnswer(res *http.Response) answer {
|
|||||||
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRequest makes a request for path to addr, where rproxy or servewaf listens.
|
// newRequest makes a request for path to addr, where rproxy or discriminator listens.
|
||||||
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS,
|
||||||
|
// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_BAN_RESPONSE, and the rate limits and
|
||||||
|
// biased thresholds through the limiter and limitPercentages.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rateLimits counts the request for the rate limits, unless
|
||||||
|
// countedByRateLimits exempts it, by the client's limit percentages, which it
|
||||||
|
// notes for the log line with the client's counts. A request that takes the
|
||||||
|
// client over a rate limit, as its limit percentage lowers it, breaks it: it is
|
||||||
|
// refused, and bans the client. A request exempt from the rate limits is exempt
|
||||||
|
// from the byte limits too. Its figures are the window, the count and the
|
||||||
|
// limit.
|
||||||
|
type rateLimits struct{}
|
||||||
|
|
||||||
|
func (rateLimits) name() string { return "rate_limits" }
|
||||||
|
|
||||||
|
func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||||
|
rq.counted = rq.countedByRateLimits()
|
||||||
|
if !rq.counted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.rateLimitPercent, rq.bytesLimitPercent = rq.limitPercentages()
|
||||||
|
rq.line.RateLimitPercent, rq.line.RateLimitPercentSetting =
|
||||||
|
rq.rateLimitPercent.logged()
|
||||||
|
rq.line.BytesLimitPercent, rq.line.BytesLimitPercentSetting =
|
||||||
|
rq.bytesLimitPercent.logged()
|
||||||
|
|
||||||
|
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
||||||
|
rq.rateLimitPercent.percent)
|
||||||
|
rq.line.Counts = counts
|
||||||
|
|
||||||
|
if !over {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionClientIsRateLimited, figures: limitFigures(hit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
|
||||||
|
// request at now, whatever its client's limit percentages: the rate limits
|
||||||
|
// count it, and one more request from its client takes it over a whole rate
|
||||||
|
// limit. It counts nothing.
|
||||||
|
func (rq *request) overAWholeRateLimit(now time.Time) bool {
|
||||||
|
return rq.countedByRateLimits() &&
|
||||||
|
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countedByRateLimits reports whether the rate limits count the request: its
|
||||||
|
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
||||||
|
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
|
||||||
|
func (rq *request) countedByRateLimits() bool {
|
||||||
|
cfg := rq.h.config
|
||||||
|
|
||||||
|
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
||||||
|
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// minute is the window of SWWAF_RATE_LIMIT_PER_MINUTE, as a log line's
|
// minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's
|
||||||
// limit_hit names it.
|
// limit_hit names it.
|
||||||
const minute = "minute"
|
const minute = "minute"
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|||||||
if sent.want == http.StatusOK {
|
if sent.want == http.StatusOK {
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
} else {
|
} else {
|
||||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
if line.LimitHit != minute {
|
if line.LimitHit != minute {
|
||||||
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
|
||||||
@@ -72,10 +72,21 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
|
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// With SWWAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
// With SWW_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
||||||
// /64s of one /48, are one client, and the second's request breaks the
|
// /64s of one /48, are one client, and the second's request breaks the
|
||||||
// limit; the third, in the next /48, is another client.
|
// limit; the third, in the next /48, is another client.
|
||||||
const (
|
const (
|
||||||
@@ -91,7 +102,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
|||||||
status int
|
status int
|
||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionRateLimited},
|
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
||||||
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
|
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
@@ -118,14 +129,14 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
|||||||
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
// The answers are kept before the requests, so that none waits for
|
// The answers are kept before the requests, so that none waits for
|
||||||
@@ -149,14 +160,17 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
|
|
||||||
// A path outside every prefix is counted: /assets is not under
|
// A path outside every prefix is counted: /assets is not under
|
||||||
// /assets/, and breaks the limit.
|
// /assets/, and breaks the limit.
|
||||||
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
s.request(client, "/assets", http.StatusForbidden,
|
||||||
|
requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
// A ban, SWWAF_DENY_NETS and the country lists still refuse a path
|
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
|
||||||
// under a prefix.
|
// prefix.
|
||||||
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/assets/app.js", http.StatusForbidden,
|
||||||
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
requestlog.ActionTemporarilyBanned)
|
||||||
|
s.request(denied, "/assets/app.js", http.StatusForbidden,
|
||||||
|
requestlog.ActionClientDenied)
|
||||||
s.request(fromKP, "/assets/app.js",
|
s.request(fromKP, "/assets/app.js",
|
||||||
http.StatusForbidden, requestlog.ActionCountryDenied)
|
http.StatusForbidden, requestlog.ActionClientDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
||||||
@@ -200,7 +214,7 @@ func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
|||||||
// Counted, the second request breaks the limit of one request
|
// Counted, the second request breaks the limit of one request
|
||||||
// a minute.
|
// a minute.
|
||||||
s.request(client, sent, http.StatusOK, requestlog.ActionForward)
|
s.request(client, sent, http.StatusOK, requestlog.ActionForward)
|
||||||
s.request(client, sent, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.request(client, sent, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
|
||||||
|
// SWW_BAN_RESPONSE.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// refusalLimit counts the request once it has been answered, if discriminator
|
||||||
|
// refused it after a rule file match, a trap path or a Core Rule Set match, and
|
||||||
|
// in observe mode if enforce mode would have: more than
|
||||||
|
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
|
||||||
|
// a limit, which bans the client. A client in SWW_ALLOW_NETS, which the
|
||||||
|
// heuristics skip, is not counted, and nothing is while the threshold is off.
|
||||||
|
// Its figures are the window, the count and the limit.
|
||||||
|
type refusalLimit struct{}
|
||||||
|
|
||||||
|
func (refusalLimit) name() string { return "refusal_limit" }
|
||||||
|
|
||||||
|
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||||
|
cfg := rq.h.config
|
||||||
|
if cfg.ErrorBurstThreshold == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rq.attack && !rq.blockedByBlockRule && !rq.blockedByCoreRuleSet {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
||||||
|
cfg.ErrorBurstThreshold)
|
||||||
|
if !over {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the client was sent, or in observe mode would have been.
|
||||||
|
status := rq.out.status
|
||||||
|
|
||||||
|
switch rq.line.WouldAction {
|
||||||
|
case requestlog.ActionBlockedByBlockRule, requestlog.ActionBlockedByCoreRuleSet:
|
||||||
|
status = http.StatusForbidden
|
||||||
|
case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned:
|
||||||
|
status = cfg.BanResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.banForLimit(now, hit, status)
|
||||||
|
|
||||||
|
return &verdict{figures: limitFigures(hit)}
|
||||||
|
}
|
||||||
+62
-30
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -10,14 +10,14 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
|
|
||||||
// refused is a request the tests here send, which servewaf refuses after a rule
|
// refused is a request the tests here send, which discriminator refuses after a rule
|
||||||
// file match.
|
// file match.
|
||||||
type refused int
|
type refused int
|
||||||
|
|
||||||
@@ -37,9 +37,10 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
switch r {
|
switch r {
|
||||||
case blockRule:
|
case blockRule:
|
||||||
return s.request(from, blockedPath, http.StatusForbidden,
|
return s.request(from, blockedPath, http.StatusForbidden,
|
||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionBlockedByBlockRule)
|
||||||
case banRule:
|
case banRule:
|
||||||
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
return s.request(from, probePath, http.StatusForbidden,
|
||||||
|
requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.t.Fatalf("no request for the refusal %d", r)
|
s.t.Fatalf("no request for the refusal %d", r)
|
||||||
@@ -48,10 +49,10 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
||||||
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||||
func startForErrorBurst(
|
func startForErrorBurst(
|
||||||
t *testing.T, threshold string, env map[string]string,
|
t *testing.T, threshold string, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server) {
|
) (*sender, *clock, *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
@@ -71,9 +72,20 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
// refusals are four, one over the threshold of three.
|
// refusals are four, one over the threshold of three.
|
||||||
refusals []refused
|
refusals []refused
|
||||||
|
// nextAction is the action of the client's next request: the next
|
||||||
|
// request after a ban for a ban rule makes it permanent.
|
||||||
|
nextAction string
|
||||||
}{
|
}{
|
||||||
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
|
{
|
||||||
{"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}},
|
"block rule",
|
||||||
|
[]refused{blockRule, blockRule, blockRule, blockRule},
|
||||||
|
requestlog.ActionTemporarilyBanned,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"a ban rule last",
|
||||||
|
[]refused{blockRule, blockRule, blockRule, banRule},
|
||||||
|
requestlog.ActionPermanentlyBanned,
|
||||||
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -95,17 +107,36 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
// The fourth is answered as the others were, breaks the error
|
// The fourth is answered as the others were, breaks the error
|
||||||
// burst, and bans the client.
|
// burst, and bans the client.
|
||||||
line := tc.refusals[3].send(s, client)
|
line := tc.refusals[3].send(s, client)
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst ||
|
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
||||||
line.Offence != requestlog.OffenceLimit {
|
line.Offence != requestlog.OffenceLimit {
|
||||||
t.Errorf("log line has limit_hit %q and offence %q, want error_burst "+
|
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals "+
|
||||||
"and limit", line.LimitHit, line.Offence)
|
"and limit", line.LimitHit, line.Offence)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, tc.nextAction)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startForErrorBurst(t, "1", nil)
|
||||||
|
|
||||||
|
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
|
||||||
|
|
||||||
|
line := blockRule.send(s, client)
|
||||||
|
wantHeuristic(t, line, "rule_files", ruleFigures)
|
||||||
|
wantAfterAnswerHeuristic(t, line, "", "null")
|
||||||
|
|
||||||
|
// The second refusal is the request refusal_limit bans for, and the line
|
||||||
|
// names both.
|
||||||
|
line = blockRule.send(s, client)
|
||||||
|
wantHeuristic(t, line, "rule_files", ruleFigures)
|
||||||
|
wantAfterAnswerHeuristic(t, line, "refusal_limit",
|
||||||
|
`{"window": "minute", "count": 2, "limit": 1}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -150,18 +181,18 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
|||||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3}
|
wantOffences := ratelimit.Offences{Limit: 1, BlockedByBlockRule: 3}
|
||||||
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
||||||
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="refusals",window="minute"}`, 1)
|
`kind="refusals",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 3)
|
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||||
@@ -172,7 +203,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
errorBurstThreshold: "2",
|
errorBurstThreshold: "2",
|
||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
countryLimitPercent: countryDEHalf,
|
tagLimitPercent: countryTagDEHalf,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Half of the threshold would be one, which the second refusal is over.
|
// Half of the threshold would be one, which the second refusal is over.
|
||||||
@@ -246,7 +277,8 @@ func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
|||||||
line = blockRule.send(s, client)
|
line = blockRule.send(s, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
if broken := line.LimitHit == requestlog.LimitHitErrorBurst; broken != tc.broken {
|
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
|
||||||
|
if broken != tc.broken {
|
||||||
t.Errorf("the 31st refusal broke the error burst: %t, want %t",
|
t.Errorf("the 31st refusal broke the error burst: %t, want %t",
|
||||||
broken, tc.broken)
|
broken, tc.broken)
|
||||||
}
|
}
|
||||||
@@ -258,15 +290,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
||||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS is not refused, and so not counted.
|
// A client in SWW_ALLOW_NETS is not refused, and so not counted.
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
@@ -275,11 +307,11 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
blockRule.send(s, exempt)
|
blockRule.send(s, exempt)
|
||||||
|
|
||||||
line := blockRule.send(s, exempt)
|
line := blockRule.send(s, exempt)
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst {
|
if line.LimitHit != requestlog.LimitHitMinuteRefusals {
|
||||||
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want minute_refusals", line.LimitHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(exempt, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||||
@@ -322,7 +354,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|||||||
for range 2 {
|
for range 2 {
|
||||||
line := s.request(otherClient, blockedPath, http.StatusOK,
|
line := s.request(otherClient, blockedPath, http.StatusOK,
|
||||||
requestlog.ActionForward)
|
requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
if line.LimitHit != "" {
|
if line.LimitHit != "" {
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||||
@@ -334,10 +366,10 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|||||||
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
|
||||||
|
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
|
if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.BanExpires != "" {
|
||||||
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
|
t.Errorf("log line has limit_hit %q and ban_expires %q, want minute_refusals "+
|
||||||
"and none", line.LimitHit, line.BanExpires)
|
"and none", line.LimitHit, line.BanExpires)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
|
||||||
|
// abuseipdb.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
|
||||||
|
// refuses the requests of a client a source lists.
|
||||||
|
const deny = "deny"
|
||||||
|
|
||||||
|
// noteListed notes each of sources, the URLs of the blocklists or the
|
||||||
|
// DNSBL zones, their keys masked, that list the client, as noteHit does,
|
||||||
|
// with reason.
|
||||||
|
func (rq *request) noteListed(sources []string, reason string) {
|
||||||
|
for _, source := range sources {
|
||||||
|
rq.noteHit(bans.ReputationHit{Source: source}, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteHit adds hit's source, which lists the client, to the log line's
|
||||||
|
// reputation, and hit to the notes of a ban the request makes, counts the
|
||||||
|
// source in the metrics, and raises a reputation_hit alert with reason,
|
||||||
|
// whose detail gives hit's source and score.
|
||||||
|
func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
||||||
|
detail := map[string]any{"source": hit.Source}
|
||||||
|
if hit.Score != nil {
|
||||||
|
detail["score"] = *hit.Score
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.line.Reputation = append(rq.line.Reputation, hit.Source)
|
||||||
|
rq.reputation = append(rq.reputation, hit)
|
||||||
|
rq.h.metrics.ReputationHit(hit.Source)
|
||||||
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventReputationHit,
|
||||||
|
Host: rq.site,
|
||||||
|
Client: rq.client,
|
||||||
|
Netblock: rq.h.clientGroup(rq.client),
|
||||||
|
ASN: rq.line.ASN,
|
||||||
|
ASName: rq.line.ASName,
|
||||||
|
Country: rq.line.Country,
|
||||||
|
Reason: reason,
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,20 +15,20 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The reputation settings.
|
// The reputation settings.
|
||||||
const (
|
const (
|
||||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWWAF_BLOCKLIST_ACTION and SWWAF_REPUTATION_ACTION:
|
// The actions of SWW_BLOCKLIST_ACTION and SWW_REPUTATION_ACTION:
|
||||||
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
||||||
// quarter.
|
// quarter.
|
||||||
const (
|
const (
|
||||||
@@ -49,12 +49,12 @@ const (
|
|||||||
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
action string
|
action string
|
||||||
// statuses and actions are those of a listed client's three
|
// statuses and actions are those of a listed client's three
|
||||||
// requests, and percent their limit_percent, as percentText gives it.
|
// requests, and percent their rate_limit_percent, as percentText gives it.
|
||||||
statuses []int
|
statuses []int
|
||||||
actions []string
|
actions []string
|
||||||
percent string
|
percent string
|
||||||
@@ -66,7 +66,7 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
|||||||
{
|
{
|
||||||
// Half of 4 requests a minute: the third breaks the limit.
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
[]string{forward, forward, requestlog.ActionRateLimited},
|
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
||||||
"50 from " + blocklistAction,
|
"50 from " + blocklistAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,8 +90,8 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
|||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
line := s.get(from, tc.statuses[i], tc.actions[i])
|
line := s.get(from, tc.statuses[i], tc.actions[i])
|
||||||
wantReputation(t, line, dropURL)
|
wantReputation(t, line, dropURL)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent,
|
wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
|
||||||
line.LimitPercentSetting, tc.percent)
|
line.RateLimitPercentSetting, tc.percent)
|
||||||
|
|
||||||
// A request refused for the list is not counted.
|
// A request refused for the list is not counted.
|
||||||
counted := line.fields["counts"] != nil
|
counted := line.fields["counts"] != nil
|
||||||
@@ -106,7 +106,8 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
|||||||
for range 3 {
|
for range 3 {
|
||||||
line := s.get(unplaced, http.StatusOK, forward)
|
line := s.get(unplaced, http.StatusOK, forward)
|
||||||
wantReputation(t, line)
|
wantReputation(t, line)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
none)
|
none)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,16 +119,66 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
|
func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
heuristic string
|
||||||
|
env map[string]string
|
||||||
|
load func(*testing.T, *discriminator.Server)
|
||||||
|
figures string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"blocklists",
|
||||||
|
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
|
||||||
|
func(t *testing.T, server *discriminator.Server) {
|
||||||
|
t.Helper()
|
||||||
|
loadLists(t, server, map[string][]string{dropURL: {client}})
|
||||||
|
},
|
||||||
|
`{"lists": ["` + dropURL + `"]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dnsbl",
|
||||||
|
map[string]string{
|
||||||
|
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||||
|
reputationAction: actionDeny,
|
||||||
|
},
|
||||||
|
func(_ *testing.T, server *discriminator.Server) {
|
||||||
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||||
|
},
|
||||||
|
`{"zones": ["` + dnsblZone + `"]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"abuseipdb",
|
||||||
|
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
||||||
|
func(_ *testing.T, server *discriminator.Server) {
|
||||||
|
loadScores(server, map[string]int64{client: 80})
|
||||||
|
},
|
||||||
|
`{"score": 80, "min_score": 75}`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.heuristic, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, tc.env)
|
||||||
|
tc.load(t, server)
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||||
|
wantHeuristic(t, line, tc.heuristic, tc.figures)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, queue := startWithLookups(t, map[string]string{
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE,
|
blocklistURLs: dropURL, allowedCountries: "de", allowNets: fromDE,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
||||||
|
|
||||||
// fromKP's country refuses it before the list is looked at, and fromDE,
|
// fromKP's country refuses it before the list is looked at, and fromDE,
|
||||||
// in SWWAF_ALLOW_NETS, is not checked at all: neither is noted, nor
|
// in SWW_ALLOW_NETS, is not checked at all: neither is noted, nor
|
||||||
// alerted.
|
// alerted.
|
||||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
||||||
@@ -143,7 +194,7 @@ func TestObserveModeForwardsAClientABlocklistDeniesAndAlertsIt(t *testing.T) {
|
|||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionDenied)
|
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
||||||
wantReputation(t, line, dropURL)
|
wantReputation(t, line, dropURL)
|
||||||
|
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
@@ -157,31 +208,33 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
|
|||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
action, asnPercent string
|
action, asnPercent string
|
||||||
// want is the upload's limit_percent and bytes_percent, as
|
// want is the upload's rate_limit_percent and bytes_limit_percent, as
|
||||||
// percentText gives them, and limitHit its limit_hit.
|
// percentText gives them, and limitHit its limit_hit.
|
||||||
want, limitHit string
|
want, limitHit string
|
||||||
}{
|
}{
|
||||||
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
{limitHalf, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
|
||||||
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes},
|
{limitQuarter, asnTagDEHalf, "25 from " + blocklistAction, minuteBytes},
|
||||||
// The AS number's, the first of two alike.
|
// The AS number's, the first of two alike.
|
||||||
{limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
{limitQuarter, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
|
||||||
{actionLog, asnDE + ":100", none, ""},
|
{actionLog, "asn=64511:25", none, ""},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
||||||
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent,
|
blocklistAction: tc.action, tagLimitPercent: tc.asnPercent,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
|
|
||||||
// The upload's 100 bytes are over 49, a quarter of 199, and 99,
|
// The upload's 100 bytes are over 49, a quarter of 199, and 99,
|
||||||
// half of it, and within 199.
|
// half of it, and within 199.
|
||||||
line := s.uploadFrom(fromDE)
|
line := s.uploadFrom(fromDE)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
tc.want)
|
tc.want)
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
wantPercent(t, "bytes_limit_percent",
|
||||||
|
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||||
tc.want)
|
tc.want)
|
||||||
|
|
||||||
if line.LimitHit != tc.limitHit {
|
if line.LimitHit != tc.limitHit {
|
||||||
@@ -196,35 +249,35 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
fromURL = "25 from " + asnLimitPercentURL
|
fromURL = "25 from " + asnLimitPercentURL
|
||||||
fromSetting = "25 from " + asnLimitPercent
|
fromSetting = "25 from " + tagLimitPercent
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
file string
|
file string
|
||||||
// limitPercent and bytesPercent are the upload's, as percentText
|
// rateLimitPercent and bytesLimitPercent are the upload's, as
|
||||||
// gives them.
|
// percentText gives them.
|
||||||
limitPercent, bytesPercent string
|
rateLimitPercent, bytesLimitPercent string
|
||||||
}{
|
}{
|
||||||
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
||||||
{
|
{
|
||||||
"the file's, lower than the setting's",
|
"the file's, lower than the setting's",
|
||||||
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL,
|
map[string]string{tagLimitPercent: asnTagDEHalf}, asnDEQuarter, fromURL, fromURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the setting's, lower than the file's",
|
"the setting's, lower than the file's",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf,
|
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEHalf,
|
||||||
fromSetting, fromSetting,
|
fromSetting, fromSetting,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the setting's, the first of two alike",
|
"the setting's, the first of two alike",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter,
|
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEQuarter,
|
||||||
fromSetting, fromSetting,
|
fromSetting, fromSetting,
|
||||||
},
|
},
|
||||||
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
||||||
{
|
{
|
||||||
"SWWAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
"SWW_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
||||||
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -238,10 +291,12 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}})
|
loadLists(t, server, map[string][]string{asnURL: {"# by AS number", tc.file}})
|
||||||
|
|
||||||
line := s.uploadFrom(fromDE)
|
line := s.uploadFrom(fromDE)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
tc.limitPercent)
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
tc.rateLimitPercent)
|
||||||
tc.bytesPercent)
|
wantPercent(t, "bytes_limit_percent",
|
||||||
|
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||||
|
tc.bytesLimitPercent)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,22 +354,22 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
|
||||||
|
|
||||||
if want.hits == 0 {
|
if want.hits == 0 {
|
||||||
wantNoSeries(t, metrics, "smallwebwaf_reputation_hits_total"+labels)
|
wantNoSeries(t, metrics, "sww_reputation_hits_total"+labels)
|
||||||
} else {
|
} else {
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, want.hits)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, want.hits)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
||||||
want.fetched)
|
want.fetched)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The DNSBL settings.
|
// The DNSBL settings.
|
||||||
const (
|
const (
|
||||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
dnsblZones = "SWW_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
reputationAction = "SWW_REPUTATION_ACTION"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The DNSBL zones these tests name, which are never asked about the
|
// The DNSBL zones these tests name, which are never asked about the
|
||||||
@@ -330,12 +385,12 @@ const (
|
|||||||
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
action string
|
action string
|
||||||
// statuses and actions are those of a listed client's three
|
// statuses and actions are those of a listed client's three
|
||||||
// requests, and percent their limit_percent, as percentText gives it.
|
// requests, and percent their rate_limit_percent, as percentText gives it.
|
||||||
statuses []int
|
statuses []int
|
||||||
actions []string
|
actions []string
|
||||||
percent string
|
percent string
|
||||||
@@ -347,7 +402,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
|||||||
{
|
{
|
||||||
// Half of 4 requests a minute: the third breaks the limit.
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
[]string{forward, forward, requestlog.ActionRateLimited},
|
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
||||||
"50 from " + reputationAction,
|
"50 from " + reputationAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -370,10 +425,10 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
|||||||
for _, from := range []string{fromDE, fromKP} {
|
for _, from := range []string{fromDE, fromKP} {
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
line := s.get(from, tc.statuses[i], tc.actions[i])
|
line := s.get(from, tc.statuses[i], tc.actions[i])
|
||||||
// In the order SWWAF_DNSBL_ZONES names them.
|
// In the order SWW_DNSBL_ZONES names them.
|
||||||
wantReputation(t, line, listedBy[from]...)
|
wantReputation(t, line, listedBy[from]...)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent,
|
wantPercent(t, "rate_limit_percent", line.RateLimitPercent,
|
||||||
line.LimitPercentSetting, tc.percent)
|
line.RateLimitPercentSetting, tc.percent)
|
||||||
|
|
||||||
// A request refused for the verdict is not counted.
|
// A request refused for the verdict is not counted.
|
||||||
counted := line.fields["counts"] != nil
|
counted := line.fields["counts"] != nil
|
||||||
@@ -388,7 +443,8 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
|||||||
for range 3 {
|
for range 3 {
|
||||||
line := s.get(unplaced, http.StatusOK, forward)
|
line := s.get(unplaced, http.StatusOK, forward)
|
||||||
wantReputation(t, line)
|
wantReputation(t, line)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
none)
|
none)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,9 +472,9 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
||||||
|
|
||||||
// The blocklist refuses fromKP before its verdict is looked at, and
|
// The blocklist refuses fromKP before its verdict is looked at, and
|
||||||
// fromDE, in SWWAF_ALLOW_NETS, is not checked at all: neither is noted
|
// fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
|
||||||
// for the zone, nor alerted, nor asked about.
|
// for the zone, nor alerted, nor asked about.
|
||||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
||||||
dropURL)
|
dropURL)
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
||||||
|
|
||||||
@@ -432,6 +488,46 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
load func(*discriminator.Server)
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"dnsbl",
|
||||||
|
map[string]string{
|
||||||
|
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
|
||||||
|
},
|
||||||
|
func(server *discriminator.Server) {
|
||||||
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
|
},
|
||||||
|
[]string{"asn=64496", "country=DE", "dnsbl=" + dnsblZone},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
abuseipdb,
|
||||||
|
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
|
||||||
|
func(server *discriminator.Server) {
|
||||||
|
loadScores(server, map[string]int64{fromDE: 80})
|
||||||
|
},
|
||||||
|
[]string{abuseipdb, "asn=64496", "country=DE"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, tc.env)
|
||||||
|
tc.load(server)
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
tc.want...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -442,7 +538,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
|||||||
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionDenied)
|
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
||||||
wantReputation(t, line, dnsblZone)
|
wantReputation(t, line, dnsblZone)
|
||||||
|
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
@@ -456,7 +552,7 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
|
|||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
blocklistAction, reputationAction string
|
blocklistAction, reputationAction string
|
||||||
// want is the request's limit_percent and bytes_percent, as
|
// want is the request's rate_limit_percent and bytes_limit_percent, as
|
||||||
// percentText gives them.
|
// percentText gives them.
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
@@ -481,9 +577,11 @@ func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
|
|||||||
// Named by the blocklist, then by the zone.
|
// Named by the blocklist, then by the zone.
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
wantReputation(t, line, dropURL, dnsblZone)
|
wantReputation(t, line, dropURL, dnsblZone)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
tc.want)
|
tc.want)
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
wantPercent(t, "bytes_limit_percent",
|
||||||
|
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||||
tc.want)
|
tc.want)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -537,9 +635,9 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
for _, zone := range []string{dnsblZone, otherZone} {
|
for _, zone := range []string{dnsblZone, otherZone} {
|
||||||
labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +665,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+
|
wantMetric(t, metrics, `sww_reputation_hits_total{instance="`+
|
||||||
alertInstance+`",source="`+masked+`"}`, 1)
|
alertInstance+`",source="`+masked+`"}`, 1)
|
||||||
|
|
||||||
for name, shown := range map[string]string{
|
for name, shown := range map[string]string{
|
||||||
@@ -609,7 +707,7 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
|
|||||||
|
|
||||||
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
||||||
const (
|
const (
|
||||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||||
accountKey = "abuseipdb-key-0123456789abcdef"
|
accountKey = "abuseipdb-key-0123456789abcdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -617,7 +715,7 @@ const (
|
|||||||
// AbuseIPDB.
|
// AbuseIPDB.
|
||||||
const abuseipdb = reputation.AbuseIPDBSource
|
const abuseipdb = reputation.AbuseIPDBSource
|
||||||
|
|
||||||
// abuseIPDBURL is where newServeWAF has clients checked with AbuseIPDB: at
|
// abuseIPDBURL is where newDiscriminator has clients checked with AbuseIPDB: at
|
||||||
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
|
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
|
||||||
// through which AbuseIPDB is asked.
|
// through which AbuseIPDB is asked.
|
||||||
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
||||||
@@ -651,8 +749,8 @@ func TestOnlyAClientThatHasCommittedAnOffenceIsCheckedWithAbuseIPDB(t *testing.T
|
|||||||
s.get(fromDE, http.StatusOK, forward)
|
s.get(fromDE, http.StatusOK, forward)
|
||||||
s.get(fromDE, http.StatusOK, forward)
|
s.get(fromDE, http.StatusOK, forward)
|
||||||
s.get(fromKP, http.StatusOK, forward)
|
s.get(fromKP, http.StatusOK, forward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
wantAbuseIPDBChecks(t, server, 0)
|
wantAbuseIPDBChecks(t, server, 0)
|
||||||
|
|
||||||
// Once the ban has ended, fromDE's first request has it checked in the
|
// Once the ban has ended, fromDE's first request has it checked in the
|
||||||
@@ -691,7 +789,7 @@ func TestIPv6ClientCostsOneAbuseIPDBCheckWhicheverOfItsAddressesSends(t *testing
|
|||||||
s.get(addresses[0], http.StatusOK, forward)
|
s.get(addresses[0], http.StatusOK, forward)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(addresses[0], http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(addresses[0], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
|
|
||||||
// Once the ban has ended, which set its counters back to zero, a
|
// Once the ban has ended, which set its counters back to zero, a
|
||||||
@@ -724,20 +822,20 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
|
|||||||
want ratelimit.Offences
|
want ratelimit.Offences
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"a block rule", blockedPath, http.StatusForbidden, requestlog.ActionRuleBlocked,
|
"a block rule", blockedPath, http.StatusForbidden,
|
||||||
ratelimit.Offences{RuleBlocked: 1},
|
requestlog.ActionBlockedByBlockRule, ratelimit.Offences{BlockedByBlockRule: 1},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the Core Rule Set", sqlInjection, http.StatusForbidden,
|
"the Core Rule Set", sqlInjection, http.StatusForbidden,
|
||||||
requestlog.ActionWAFBlocked, ratelimit.Offences{WAFBlocked: 1},
|
requestlog.ActionBlockedByCoreRuleSet, ratelimit.Offences{BlockedByCoreRuleSet: 1},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionBanned,
|
"a ban rule", probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned,
|
||||||
ratelimit.Offences{Attack: 1},
|
ratelimit.Offences{Attack: 1},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
|
"a trap path", "/xmlrpc.php", http.StatusForbidden,
|
||||||
ratelimit.Offences{Attack: 1},
|
requestlog.ActionTemporarilyBanned, ratelimit.Offences{Attack: 1},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -768,12 +866,12 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
|
|||||||
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
|
func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
forward, denied := requestlog.ActionForward, requestlog.ActionDenied
|
forward, denied := requestlog.ActionForward, requestlog.ActionClientDenied
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
action string
|
action string
|
||||||
// statuses and actions are those of fromDE's three requests, and
|
// statuses and actions are those of fromDE's three requests, and
|
||||||
// percent their limit_percent, as percentText gives it.
|
// percent their rate_limit_percent, as percentText gives it.
|
||||||
statuses []int
|
statuses []int
|
||||||
actions []string
|
actions []string
|
||||||
percent string
|
percent string
|
||||||
@@ -785,7 +883,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
|
|||||||
{
|
{
|
||||||
// Half of 4 requests a minute: the third breaks the limit.
|
// Half of 4 requests a minute: the third breaks the limit.
|
||||||
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
limitHalf, []int{http.StatusOK, http.StatusOK, http.StatusForbidden},
|
||||||
[]string{forward, forward, requestlog.ActionRateLimited},
|
[]string{forward, forward, requestlog.ActionClientIsRateLimited},
|
||||||
"50 from " + reputationAction,
|
"50 from " + reputationAction,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -800,13 +898,14 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
|
|||||||
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
||||||
reputationAction: tc.action,
|
reputationAction: tc.action,
|
||||||
})
|
})
|
||||||
// At SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
// At SWW_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
||||||
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
||||||
|
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
line := s.get(fromDE, tc.statuses[i], tc.actions[i])
|
line := s.get(fromDE, tc.statuses[i], tc.actions[i])
|
||||||
wantReputation(t, line, abuseipdb)
|
wantReputation(t, line, abuseipdb)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
tc.percent)
|
tc.percent)
|
||||||
|
|
||||||
// A request refused for the score is not counted.
|
// A request refused for the score is not counted.
|
||||||
@@ -820,7 +919,8 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
|
|||||||
for range 3 {
|
for range 3 {
|
||||||
line := s.get(fromKP, http.StatusOK, forward)
|
line := s.get(fromKP, http.StatusOK, forward)
|
||||||
wantReputation(t, line)
|
wantReputation(t, line)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
none)
|
none)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,7 +951,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
|||||||
// The alert is made as a DNSBL zone's is, with the score besides.
|
// The alert is made as a DNSBL zone's is, with the score besides.
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
||||||
waiting[0].Reason != "scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE" ||
|
waiting[0].Reason != "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE" ||
|
||||||
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
||||||
queue.Suppressed() != 1 {
|
queue.Suppressed() != 1 {
|
||||||
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
||||||
@@ -863,10 +963,10 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
|||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_daily_budget_remaining"+labels, 900)
|
wantMetric(t, metrics, "sww_reputation_daily_budget_remaining"+labels, 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
||||||
@@ -882,12 +982,12 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
|||||||
// fromDE's score is not used, and once it has committed an offence it
|
// fromDE's score is not used, and once it has committed an offence it
|
||||||
// is not checked either.
|
// is not checked either.
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||||
wantAbuseIPDBChecks(t, server, 0)
|
wantAbuseIPDBChecks(t, server, 0)
|
||||||
|
|
||||||
wantNoSeries(t, s.scrape(), `smallwebwaf_reputation_daily_budget_remaining{`+
|
wantNoSeries(t, s.scrape(), `sww_reputation_daily_budget_remaining{`+
|
||||||
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
|
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,10 +1006,10 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"for a broken rate limit", func(s *sender, from string) {
|
{"for a broken rate limit", func(s *sender, from string) {
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
|
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
}},
|
}},
|
||||||
{"for a clear sign of attack", func(s *sender, from string) {
|
{"for a clear sign of attack", func(s *sender, from string) {
|
||||||
s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
s.request(from, probePath, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}},
|
}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -922,7 +1022,7 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
|
|||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||||
})
|
})
|
||||||
// Every source lists client, and none otherClient, whose score is
|
// Every source lists client, and none otherClient, whose score is
|
||||||
// under SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
// under SWW_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||||
loadLists(t, server, map[string][]string{dropURL: {client}})
|
loadLists(t, server, map[string][]string{dropURL: {client}})
|
||||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
|
||||||
loadScores(server, map[string]int64{client: score, otherClient: 74})
|
loadScores(server, map[string]int64{client: score, otherClient: 74})
|
||||||
@@ -971,11 +1071,11 @@ func verdictsFetched() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
||||||
// a verdict of each zone SWWAF_DNSBL_ZONES names, fetched at
|
// a verdict of each zone SWW_DNSBL_ZONES names, fetched at
|
||||||
// verdictsFetched, as reputation.json would at start: one that lists the
|
// verdictsFetched, as reputation.json would at start: one that lists the
|
||||||
// client from each zone listedBy gives for it, and one that does not from
|
// client from each zone listedBy gives for it, and one that does not from
|
||||||
// each other zone.
|
// each other zone.
|
||||||
func loadVerdicts(server *servewaf.Server, listedBy map[string][]string) {
|
func loadVerdicts(server *discriminator.Server, listedBy map[string][]string) {
|
||||||
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
|
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
|
||||||
|
|
||||||
for client, zones := range listedBy {
|
for client, zones := range listedBy {
|
||||||
@@ -993,7 +1093,7 @@ func loadVerdicts(server *servewaf.Server, listedBy map[string][]string) {
|
|||||||
// loadScores puts into server's AbuseIPDB the score scores gives each
|
// loadScores puts into server's AbuseIPDB the score scores gives each
|
||||||
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
||||||
// would at start.
|
// would at start.
|
||||||
func loadScores(server *servewaf.Server, scores map[string]int64) {
|
func loadScores(server *discriminator.Server, scores map[string]int64) {
|
||||||
kept := make([]reputation.Score, 0, len(scores))
|
kept := make([]reputation.Score, 0, len(scores))
|
||||||
for client, score := range scores {
|
for client, score := range scores {
|
||||||
kept = append(kept, reputation.Score{
|
kept = append(kept, reputation.Score{
|
||||||
@@ -1007,7 +1107,7 @@ func loadScores(server *servewaf.Server, scores map[string]int64) {
|
|||||||
|
|
||||||
// wantAbuseIPDBChecks checks how many clients server has checked with
|
// wantAbuseIPDBChecks checks how many clients server has checked with
|
||||||
// AbuseIPDB.
|
// AbuseIPDB.
|
||||||
func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) {
|
func wantAbuseIPDBChecks(t *testing.T, server *discriminator.Server, want int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if got := server.AbuseIPDB.Checked(); got != want {
|
if got := server.AbuseIPDB.Checked(); got != want {
|
||||||
@@ -1017,7 +1117,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) {
|
|||||||
|
|
||||||
// loadLists puts copies of lists into server's lists, by URL, each with
|
// loadLists puts copies of lists into server's lists, by URL, each with
|
||||||
// its lines, fetched at listsFetched, as reputation.json would at start.
|
// its lines, fetched at listsFetched, as reputation.json would at start.
|
||||||
func loadLists(t *testing.T, server *servewaf.Server, copies map[string][]string) {
|
func loadLists(t *testing.T, server *discriminator.Server, copies map[string][]string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
lists := make([]reputation.List, 0, len(copies))
|
lists := make([]reputation.List, 0, len(copies))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// refusal is servewaf refusing a request: the status the visitor is to get, 0
|
// refusal is discriminator refusing a request: the status the visitor is to get, 0
|
||||||
// to close its connection without an answer, and the action the log line names.
|
// to close its connection without an answer, and the action the log line names.
|
||||||
type refusal struct {
|
type refusal struct {
|
||||||
status int
|
status int
|
||||||
@@ -25,7 +25,7 @@ type refusal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decision is what the checks note of a request: its log line, and what
|
// decision is what the checks note of a request: its log line, and what
|
||||||
// counting the request needs once it has ended. servewaf keeps a copy of it
|
// counting the request needs once it has ended. discriminator keeps a copy of it
|
||||||
// until the request's report comes. It holds no credentials, since the log line
|
// until the request's report comes. It holds no credentials, since the log line
|
||||||
// never does, and neither the request's objects nor their bodies.
|
// never does, and neither the request's objects nor their bodies.
|
||||||
type decision struct {
|
type decision struct {
|
||||||
@@ -34,7 +34,7 @@ type decision struct {
|
|||||||
// requestURI is the path with the query, as a ban's notes keep it.
|
// requestURI is the path with the query, as a ban's notes keep it.
|
||||||
requestURI string
|
requestURI string
|
||||||
// site is, for a question, the host the visitor asked for, which the
|
// site is, for a question, the host the visitor asked for, which the
|
||||||
// alerts name, since one servewaf serves many sites.
|
// alerts name, since one discriminator serves many sites.
|
||||||
site string
|
site string
|
||||||
// allowed is true for a question answered with DispositionAllow.
|
// allowed is true for a question answered with DispositionAllow.
|
||||||
allowed bool
|
allowed bool
|
||||||
@@ -44,26 +44,26 @@ type decision struct {
|
|||||||
lookedUp bool
|
lookedUp bool
|
||||||
lookupAnswer lookup.Answer
|
lookupAnswer lookup.Answer
|
||||||
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
||||||
// found no answer kept. SWWAF_UNKNOWN_LIMIT_PERCENT does not apply to it,
|
// found no answer kept. SWW_UNKNOWN_LIMIT_PERCENT does not apply to it,
|
||||||
// so that not waiting never lowers its client's limits.
|
// so that not waiting never lowers its client's limits.
|
||||||
skippedLookupWait bool
|
skippedLookupWait bool
|
||||||
// counted is true for a request the rate limits counted, whose bytes
|
// counted is true for a request the rate limits counted, whose bytes
|
||||||
// the byte limits count once it has ended. limitPercent and
|
// the byte limits count once it has ended. rateLimitPercent and
|
||||||
// bytesPercent are then its client's limit percentages for the rate
|
// bytesLimitPercent are then its client's limit percentages for the rate
|
||||||
// limits and for the byte limits.
|
// limits and for the byte limits.
|
||||||
counted bool
|
counted bool
|
||||||
limitPercent, bytesPercent percentage
|
rateLimitPercent, bytesLimitPercent percentage
|
||||||
// attack is true for a request that matched a ban rule or asked for a
|
// attack is true for a request that matched a ban rule or asked for a
|
||||||
// trap path, ruleBlocked for one a block rule refused, and wafBlocked for
|
// trap path, blockedByBlockRule for one a block rule refused, and
|
||||||
// one the Core Rule Set refused, each an offence its client's history
|
// blockedByCoreRuleSet for one the Core Rule Set refused, each an offence
|
||||||
// counts.
|
// its client's history counts.
|
||||||
attack, ruleBlocked, wafBlocked bool
|
attack, blockedByBlockRule, blockedByCoreRuleSet bool
|
||||||
// reputation is the reputation sources that list the client, for the
|
// reputation is the reputation sources that list the client, for the
|
||||||
// notes of a ban the request makes.
|
// notes of a ban the request makes.
|
||||||
reputation []bans.ReputationHit
|
reputation []bans.ReputationHit
|
||||||
}
|
}
|
||||||
|
|
||||||
// request is one request to servewaf, from the moment its headers have been
|
// request is one request to discriminator, from the moment its headers have been
|
||||||
// read to its log line.
|
// read to its log line.
|
||||||
type request struct {
|
type request struct {
|
||||||
decision
|
decision
|
||||||
@@ -138,6 +138,15 @@ func (h *handler) startRequest(
|
|||||||
return rq
|
return rq
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addTag adds tag to the request's tags, which stay sorted and hold each tag
|
||||||
|
// once.
|
||||||
|
func (rq *request) addTag(tag string) {
|
||||||
|
at, found := slices.BinarySearch(rq.line.Tags, tag)
|
||||||
|
if !found {
|
||||||
|
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// requestHeaders returns the headers of r that names lists, by name in
|
// requestHeaders returns the headers of r that names lists, by name in
|
||||||
// lower case, each with its values joined by ", ". Authorization, Cookie
|
// lower case, each with its values joined by ", ". Authorization, Cookie
|
||||||
// and Set-Cookie are never among them, whatever names says.
|
// and Set-Cookie are never among them, whatever names says.
|
||||||
@@ -160,58 +169,49 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check is the one place where a request can be refused. It returns nil to let
|
// check is the one place where a request can be refused. It returns nil to let
|
||||||
// the request through. The checks of checkClient decide: a request they refuse
|
// the request through. The heuristics of checkClient decide: a request they
|
||||||
// is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
|
// refuse is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core
|
||||||
// or 431 for the header limits. In observe mode nothing is refused, and the log
|
// Rule Set, or 431 for the header limits. In observe mode nothing is refused,
|
||||||
// line names what enforce mode would have done. ctx is the request's own context.
|
// and the log line names what enforce mode would have done. ctx is the
|
||||||
|
// request's own context.
|
||||||
func (rq *request) check(ctx context.Context) *refusal {
|
func (rq *request) check(ctx context.Context) *refusal {
|
||||||
action := rq.checkClient(ctx)
|
v := rq.checkClient(ctx)
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
action := v.action
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case action == "":
|
|
||||||
return nil
|
|
||||||
case rq.h.config.Observe:
|
case rq.h.config.Observe:
|
||||||
rq.line.WouldAction = action
|
rq.line.WouldAction = action
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
|
case action == requestlog.ActionBlockedByBlockRule ||
|
||||||
|
action == requestlog.ActionBlockedByCoreRuleSet:
|
||||||
return &refusal{status: http.StatusForbidden, action: action}
|
return &refusal{status: http.StatusForbidden, action: action}
|
||||||
case action == requestlog.ActionTooLarge:
|
case action == requestlog.ActionRequestHeaderTooLarge:
|
||||||
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
|
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
|
||||||
default:
|
default:
|
||||||
return rq.banResponse(action)
|
return rq.banResponse(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkClient runs the checks on the request's client, and returns the
|
// checkClient runs the heuristics of beforeLookup on the request, then looks
|
||||||
// action of the first that refuses the request, or "" when none does. A
|
// up the client's AS number and country, then runs those of afterLookup. It
|
||||||
// client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any
|
// returns the first verdict, or nil when none gives one. A client in
|
||||||
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock,
|
// SWW_ALLOW_NETS skips them all, and is not looked up. ctx is the request's
|
||||||
// so that a client either refuses is not looked up, then the lookup of
|
// own context.
|
||||||
// its AS number and country, then the country lists, then the blocklists,
|
func (rq *request) checkClient(ctx context.Context) *verdict {
|
||||||
// then the CrowdSec decision list, which bans the client it lists, then
|
if isInside(rq.client, rq.h.config.AllowNets) {
|
||||||
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
|
return nil
|
||||||
// them refuses is not counted for the rate limits. Then come the
|
|
||||||
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
|
||||||
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
|
||||||
// every other request is counted, each of them by the client's limit
|
|
||||||
// percentages, then SWWAF_TRAP_PATHS, then the rule files, then the header
|
|
||||||
// limits, and last the Core Rule Set. A request exempt from the rate limits
|
|
||||||
// is exempt from the byte limits too. ctx is the request's own context.
|
|
||||||
func (rq *request) checkClient(ctx context.Context) string {
|
|
||||||
cfg := rq.h.config
|
|
||||||
if isInside(rq.client, cfg.AllowNets) {
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
now := rq.h.now()
|
now := rq.h.now()
|
||||||
|
|
||||||
if isInside(rq.client, cfg.DenyNets) {
|
v := rq.runHeuristics(ctx, beforeLookup(), now)
|
||||||
return requestlog.ActionDenied
|
if v != nil {
|
||||||
}
|
return v
|
||||||
|
|
||||||
if rq.banned(now) {
|
|
||||||
return requestlog.ActionBanned
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.lookUp(ctx, now)
|
rq.lookUp(ctx, now)
|
||||||
@@ -220,63 +220,11 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
// again: a request counted at the time it arrived could be counted
|
// again: a request counted at the time it arrived could be counted
|
||||||
// after a later one of its client's, which the rate limits would take
|
// after a later one of its client's, which the rate limits would take
|
||||||
// for the clock set back.
|
// for the clock set back.
|
||||||
now = rq.h.now()
|
return rq.runHeuristics(ctx, afterLookup(), rq.h.now())
|
||||||
|
|
||||||
if rq.countryDenied() {
|
|
||||||
return requestlog.ActionCountryDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.blocklistDenied() {
|
|
||||||
return requestlog.ActionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.crowdSecBanned(now) {
|
|
||||||
return requestlog.ActionBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
|
|
||||||
return requestlog.ActionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.counted = rq.countedByRateLimits()
|
|
||||||
if rq.counted {
|
|
||||||
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
|
|
||||||
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
|
|
||||||
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.counted && rq.limitBroken(now) {
|
|
||||||
return requestlog.ActionRateLimited
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.trapPath(now) {
|
|
||||||
return requestlog.ActionBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
action := rq.checkRules(now)
|
|
||||||
if action != "" {
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.overHeaderLimits() {
|
|
||||||
return requestlog.ActionTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
return rq.checkCoreRuleSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
// countedByRateLimits reports whether the rate limits count the request: its
|
|
||||||
// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
|
||||||
// under SWWAF_RATE_LIMIT_EXEMPT_PATHS.
|
|
||||||
func (rq *request) countedByRateLimits() bool {
|
|
||||||
cfg := rq.h.config
|
|
||||||
|
|
||||||
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
|
||||||
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathExempt reports whether r is exempt under prefixes,
|
// pathExempt reports whether r is exempt under prefixes,
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its
|
// SWW_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_EXEMPT_PATHS: whether its
|
||||||
// path as sent, the path the app receives, not percent-decoded, starts
|
// path as sent, the path the app receives, not percent-decoded, starts
|
||||||
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
|
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
|
||||||
// app whose router matches the path as received. A request whose decoded
|
// app whose router matches the path as received. A request whose decoded
|
||||||
@@ -314,8 +262,8 @@ func (rq *request) finish() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
duration := time.Since(rq.start)
|
duration := time.Since(rq.start)
|
||||||
line.DurationTotal = requestlog.Milliseconds(duration)
|
line.DurationTotalMS = requestlog.Milliseconds(duration)
|
||||||
line.DurationChecks = timing(rq.start, rq.checked)
|
line.DurationChecksMS = timing(rq.start, rq.checked)
|
||||||
|
|
||||||
// Counted before the log line is written, so that the metrics count
|
// Counted before the log line is written, so that the metrics count
|
||||||
// every request whose line is out. An allowed request is the exception:
|
// every request whose line is out. An allowed request is the exception:
|
||||||
@@ -356,15 +304,15 @@ func timing(start, end time.Time) *float64 {
|
|||||||
// when it comes.
|
// when it comes.
|
||||||
func (rq *request) addToHistory() {
|
func (rq *request) addToHistory() {
|
||||||
request := ratelimit.Request{
|
request := ratelimit.Request{
|
||||||
Forwarded: rq.allowed,
|
Forwarded: rq.allowed,
|
||||||
Refused: rq.refused != nil,
|
Refused: rq.refused != nil,
|
||||||
Status: rq.out.status,
|
Status: rq.out.status,
|
||||||
RequestBytes: rq.requestBytes,
|
RequestBytes: rq.requestBytes,
|
||||||
ResponseBytes: rq.out.bytes,
|
ResponseBytes: rq.out.bytes,
|
||||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||||
Attack: rq.attack,
|
Attack: rq.attack,
|
||||||
RuleBlocked: rq.ruleBlocked,
|
BlockedByBlockRule: rq.blockedByBlockRule,
|
||||||
WAFBlocked: rq.wafBlocked,
|
BlockedByCoreRuleSet: rq.blockedByCoreRuleSet,
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
|
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
|
||||||
@@ -378,9 +326,9 @@ func (rq *request) addToHistory() {
|
|||||||
|
|
||||||
// countAnomalies counts the request, which has ended, and its bytes, as
|
// countAnomalies counts the request, which has ended, and its bytes, as
|
||||||
// countedBytes gives them, for the anomaly thresholds, whatever was done
|
// countedBytes gives them, for the anomaly thresholds, whatever was done
|
||||||
// with it: a request refused, one from a client in SWWAF_ALLOW_NETS or
|
// with it: a request refused, one from a client in SWW_ALLOW_NETS or
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in
|
// SWW_RATE_LIMIT_EXEMPT_NETS, and one for a path in
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
|
// SWW_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
|
||||||
// client's AS number when answerAtTheEnd gives one. With every anomaly
|
// client's AS number when answerAtTheEnd gives one. With every anomaly
|
||||||
// threshold off, the default, it does nothing.
|
// threshold off, the default, it does nothing.
|
||||||
func (rq *request) countAnomalies() {
|
func (rq *request) countAnomalies() {
|
||||||
@@ -425,7 +373,7 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
|
|||||||
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
|
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
|
||||||
}
|
}
|
||||||
|
|
||||||
// responseWriter is servewaf's answer to a request. It notes the status and
|
// responseWriter is discriminator's answer to a request. It notes the status and
|
||||||
// size for the log line, and the first error writing the answer.
|
// size for the log line, and the first error writing the answer.
|
||||||
type responseWriter struct {
|
type responseWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,16 +11,16 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// requestIDHeader carries the request's id.
|
// requestIDHeader carries the request's id.
|
||||||
requestIDHeader = "X-Request-ID"
|
requestIDHeader = "X-Request-ID"
|
||||||
// instance is the SWWAF_INSTANCE_NAME a test sets.
|
// instance is the SWW_INSTANCE_NAME a test sets.
|
||||||
instance = "fsn1app1/gitea"
|
instance = "fsn1app1/blog"
|
||||||
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
||||||
// limits count it as.
|
// limits count it as.
|
||||||
ipv6Client = "2001:db8::7"
|
ipv6Client = "2001:db8::7"
|
||||||
@@ -104,7 +105,7 @@ func wantFullLine(t *testing.T, line logLine) {
|
|||||||
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
||||||
|
|
||||||
want := withTimings(line, requestlog.Line{
|
want := withTimings(line, requestlog.Line{
|
||||||
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport,
|
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||||
Time: line.Time, Instance: instance,
|
Time: line.Time, Instance: instance,
|
||||||
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
||||||
Host: appHost, Path: "/full", Protocol: protocol,
|
Host: appHost, Path: "/full", Protocol: protocol,
|
||||||
@@ -130,7 +131,7 @@ func wantBareLine(t *testing.T, line logLine) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
want := withTimings(line, requestlog.Line{
|
want := withTimings(line, requestlog.Line{
|
||||||
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport,
|
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||||
Time: line.Time, Instance: instance,
|
Time: line.Time, Instance: instance,
|
||||||
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
||||||
Host: appHost, Path: "/bare", Protocol: protocol,
|
Host: appHost, Path: "/bare", Protocol: protocol,
|
||||||
@@ -155,9 +156,9 @@ func wantBareLine(t *testing.T, line logLine) {
|
|||||||
|
|
||||||
// withTimings returns want with the timings of line.
|
// withTimings returns want with the timings of line.
|
||||||
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
|
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
|
||||||
want.DurationTotal = line.DurationTotal
|
want.DurationTotalMS = line.DurationTotalMS
|
||||||
want.DurationChecks = line.DurationChecks
|
want.DurationChecksMS = line.DurationChecksMS
|
||||||
want.DurationUpstreamTotal = line.DurationUpstreamTotal
|
want.DurationUpstreamTotalMS = line.DurationUpstreamTotalMS
|
||||||
|
|
||||||
return want
|
return want
|
||||||
}
|
}
|
||||||
@@ -195,7 +196,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
|||||||
func TestTimingsAreInOrder(t *testing.T) {
|
func TestTimingsAreInOrder(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
// The pause sets the times apart; a hold-up of the test only lengthens
|
// The pause sets the times apart; a hold-up of the test only lengthens
|
||||||
@@ -220,15 +221,15 @@ func TestTimingsAreInOrder(t *testing.T) {
|
|||||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||||
refused := out.endedLines(t, 2)[1]
|
refused := out.endedLines(t, 2)[1]
|
||||||
|
|
||||||
wantStatus(t, get(t, server.Addr, servewaf.HealthPath), http.StatusOK)
|
wantStatus(t, get(t, server.Addr, discriminator.HealthPath), http.StatusOK)
|
||||||
health := out.endedLines(t, 3)[2]
|
health := out.endedLines(t, 3)[2]
|
||||||
|
|
||||||
// A request passed to the app has every timing; one refused, none of
|
// A request passed to the app has every timing; one refused, none of
|
||||||
// the app's; the health check, which runs no check, only the total.
|
// the app's; the health check, which runs no check, only the total.
|
||||||
wantTimings(t, forwarded, "duration_total", "duration_checks",
|
wantTimings(t, forwarded, "duration_total_ms", "duration_checks_ms",
|
||||||
"duration_upstream_total")
|
"duration_upstream_total_ms")
|
||||||
wantTimings(t, refused, "duration_total", "duration_checks")
|
wantTimings(t, refused, "duration_total_ms", "duration_checks_ms")
|
||||||
wantTimings(t, health, "duration_total")
|
wantTimings(t, health, "duration_total_ms")
|
||||||
|
|
||||||
if t.Failed() {
|
if t.Failed() {
|
||||||
return
|
return
|
||||||
@@ -236,9 +237,9 @@ func TestTimingsAreInOrder(t *testing.T) {
|
|||||||
|
|
||||||
// In whole microseconds, as they are logged, so that the sum below is
|
// In whole microseconds, as they are logged, so that the sum below is
|
||||||
// exact.
|
// exact.
|
||||||
total := microseconds(forwarded.DurationTotal)
|
total := microseconds(forwarded.DurationTotalMS)
|
||||||
checks := microseconds(*forwarded.DurationChecks)
|
checks := microseconds(*forwarded.DurationChecksMS)
|
||||||
upstream := microseconds(*forwarded.DurationUpstreamTotal)
|
upstream := microseconds(*forwarded.DurationUpstreamTotalMS)
|
||||||
|
|
||||||
// The checks end before the request is handed to the app.
|
// The checks end before the request is handed to the app.
|
||||||
if checks+upstream > total {
|
if checks+upstream > total {
|
||||||
@@ -246,9 +247,9 @@ func TestTimingsAreInOrder(t *testing.T) {
|
|||||||
total, checks, upstream)
|
total, checks, upstream)
|
||||||
}
|
}
|
||||||
|
|
||||||
if *refused.DurationChecks > refused.DurationTotal {
|
if *refused.DurationChecksMS > refused.DurationTotalMS {
|
||||||
t.Errorf("refused request's checks took %v of %v milliseconds",
|
t.Errorf("refused request's checks took %v of %v milliseconds",
|
||||||
*refused.DurationChecks, refused.DurationTotal)
|
*refused.DurationChecksMS, refused.DurationTotalMS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +273,48 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wantHeuristic checks that line names heuristic as the one that refused the
|
||||||
|
// request, with figures, a JSON object. A heuristic of "" with figures "null" is
|
||||||
|
// a line that names none.
|
||||||
|
func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if line.Heuristic != heuristic ||
|
||||||
|
!reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) {
|
||||||
|
t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s",
|
||||||
|
line.Heuristic, line.HeuristicFigures, heuristic, figures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantAfterAnswerHeuristic checks after_answer_heuristic and
|
||||||
|
// after_answer_heuristic_figures as wantHeuristic checks heuristic and
|
||||||
|
// heuristic_figures.
|
||||||
|
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if line.AfterAnswerHeuristic != heuristic ||
|
||||||
|
!reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
|
||||||
|
t.Errorf("log line has after_answer_heuristic %q with "+
|
||||||
|
"after_answer_heuristic_figures %v, want %q with %s",
|
||||||
|
line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFigures decodes figures, a JSON object of a heuristic's figures, as
|
||||||
|
// the log line's figures are decoded.
|
||||||
|
func decodeFigures(t *testing.T, figures string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(figures), &decoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", figures, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
// microseconds is a timing in whole microseconds.
|
// microseconds is a timing in whole microseconds.
|
||||||
func microseconds(milliseconds float64) int64 {
|
func microseconds(milliseconds float64) int64 {
|
||||||
return int64(math.Round(milliseconds * 1000))
|
return int64(math.Round(milliseconds * 1000))
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// routeWatchTags adds to the request the tags asn=<number> and prefix=<netblock>
|
||||||
|
// from what routewatch answered about the client. It refuses nothing, and the
|
||||||
|
// request does not wait for routewatch. A request that gets no tags from it has
|
||||||
|
// the reason in the log line's routewatch_tags_missing. While SWW_ROUTEWATCH_URL
|
||||||
|
// is unset it does nothing. Answers are kept by client and asked for by the
|
||||||
|
// request's address, and a route without an origin AS gives only the prefix= tag.
|
||||||
|
type routeWatchTags struct{}
|
||||||
|
|
||||||
|
func (routeWatchTags) name() string { return "routewatch_tags" }
|
||||||
|
|
||||||
|
func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
if rq.h.routeWatch == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client)
|
||||||
|
if missing != "" {
|
||||||
|
rq.line.RouteWatchTagsMissing = string(missing)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.addTag(config.PrefixTagPrefix + route.Netblock.String())
|
||||||
|
|
||||||
|
if route.ASN != 0 {
|
||||||
|
rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package discriminator_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field
|
||||||
|
// that says why a request got no tags from routewatch.
|
||||||
|
const (
|
||||||
|
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
||||||
|
tagsMissing = "routewatch_tags_missing"
|
||||||
|
fromV6 = "2001:db8:1::5"
|
||||||
|
prefixV6 = "2001:db8::/32"
|
||||||
|
prefixDE = "203.0.113.0/24"
|
||||||
|
prefixKP = "198.51.100.0/24"
|
||||||
|
)
|
||||||
|
|
||||||
|
// standInRoute is what the stand-in for routewatch answers about an address.
|
||||||
|
type standInRoute struct {
|
||||||
|
Netblock string `json:"netblock"`
|
||||||
|
ASN int `json:"asn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRouteWatch starts a stand-in for routewatch's IP info API that knows the
|
||||||
|
// routes given by address, and returns its URL.
|
||||||
|
func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
address := strings.TrimPrefix(r.URL.Path, "/api/v1/ip/")
|
||||||
|
|
||||||
|
result := map[string]any{"ip": address}
|
||||||
|
if route, known := routes[address]; known {
|
||||||
|
result["netblock"], result["asn"] = route.Netblock, route.ASN
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"data": map[string]any{"results": []any{result}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
return server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForRouteWatch waits until routewatch's answer about from, in the client
|
||||||
|
// group of the given length, is kept, so that the next request gets its tags.
|
||||||
|
func waitForRouteWatch(
|
||||||
|
t *testing.T, server *discriminator.Server, from string, bits int,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
addr := netip.MustParseAddr(from)
|
||||||
|
client := netip.PrefixFrom(addr, bits).Masked()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, missing := server.RouteWatch.Route(context.Background(), client, addr)
|
||||||
|
if missing == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("routewatch's answer about %s not kept: %q", from, missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantTagsMissing(t *testing.T, line logLine, want string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got, _ := line.fields[tagsMissing].(string)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("log line has %s %q, want %q", tagsMissing, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}})
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromDE, 32)
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantTags(t, line, "asn=64496", "country=DE", "prefix="+prefixDE)
|
||||||
|
wantTagsMissing(t, line, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}})
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url, denyNets: fromKP,
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromKP, 32)
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||||
|
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`)
|
||||||
|
wantTags(t, line, "asn=64511", "prefix="+prefixKP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 0}})
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url,
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromDE, 32)
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"prefix="+prefixDE)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsetRouteWatchChangesNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{lookupSource: off})
|
||||||
|
|
||||||
|
if server.RouteWatch != nil {
|
||||||
|
t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset")
|
||||||
|
}
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
if _, present := line.fields[tagsMissing]; present {
|
||||||
|
t.Errorf("log line has %s", tagsMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, present := line.fields["tags"]; present {
|
||||||
|
t.Errorf("log line has tags %v", line.fields["tags"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A stand-in that is closed at once, so that the port refuses connections.
|
||||||
|
dead := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
url := dead.URL
|
||||||
|
|
||||||
|
dead.Close()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url,
|
||||||
|
})
|
||||||
|
|
||||||
|
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"no_answer_yet")
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
missing, _ := line.fields[tagsMissing].(string)
|
||||||
|
if missing == "request_failed" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("still %q, want request_failed", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddressWithoutARouteHasNoTagsAndTheLineSaysSo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: startRouteWatch(t, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
missing, _ := line.fields[tagsMissing].(string)
|
||||||
|
if missing == "no_route" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("still %q, want no_route", missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{
|
||||||
|
fromDE: {prefixDE, 64496}, fromKP: {prefixKP, 64511},
|
||||||
|
})
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url, tagDeny: "prefix=" + prefixKP,
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromKP, 32)
|
||||||
|
waitForRouteWatch(t, server, fromDE, 32)
|
||||||
|
|
||||||
|
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
||||||
|
"tag_deny", `{"tag": "prefix=`+prefixKP+`"}`)
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}})
|
||||||
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
|
lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2",
|
||||||
|
tagLimitPercent: "prefix=" + prefixV6 + ":50",
|
||||||
|
})
|
||||||
|
|
||||||
|
waitForRouteWatch(t, server, fromV6, 64)
|
||||||
|
|
||||||
|
// Half of two is one: the second request breaks the limit.
|
||||||
|
line := s.get(fromV6, http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantTags(t, line, "asn=64496", "prefix="+prefixV6)
|
||||||
|
s.get(fromV6, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic rule_files. It reads the rules of the rule files, which
|
||||||
|
// SWW_RULES_DIR and SWW_RULES_ENABLED name.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ruleFiles checks the request against the rules of the rule files at now,
|
||||||
|
// notes the ids of those it matches in the log line, adds the tag
|
||||||
|
// exploitsignature when it matches any, and refuses the request
|
||||||
|
// when the last rule it matches refuses it: with ActionBlockedByBlockRule for a
|
||||||
|
// block rule, and for a ban rule with the action for the ban it makes. A ban
|
||||||
|
// rule bans the client's netblock for a clear sign of attack, or in observe
|
||||||
|
// mode raises the alert for the ban
|
||||||
|
// it would have made. Either rule's match is noted as an offence, for the
|
||||||
|
// client's history. Its figures are the rule's id and action.
|
||||||
|
type ruleFiles struct{}
|
||||||
|
|
||||||
|
func (ruleFiles) name() string { return "rule_files" }
|
||||||
|
|
||||||
|
func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||||
|
matched := rq.h.rules.Match(rq.in)
|
||||||
|
|
||||||
|
for _, rule := range matched {
|
||||||
|
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
|
||||||
|
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matched) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.addTag(config.ExploitSignatureTag)
|
||||||
|
|
||||||
|
// Only the last rule matched can refuse the request.
|
||||||
|
last := matched[len(matched)-1]
|
||||||
|
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
|
||||||
|
|
||||||
|
switch last.Action {
|
||||||
|
case rules.ActionBlock:
|
||||||
|
rq.blockedByBlockRule = true
|
||||||
|
|
||||||
|
return &verdict{action: requestlog.ActionBlockedByBlockRule, figures: figures}
|
||||||
|
case rules.ActionBan:
|
||||||
|
rq.attack = true
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target}),
|
||||||
|
figures: figures,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -34,16 +34,17 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantRuleIDs(t, line, "noted")
|
wantRuleIDs(t, line, "noted")
|
||||||
|
|
||||||
// A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and
|
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
|
||||||
// bans no one.
|
// bans no one.
|
||||||
line = s.request(client, "/blocked", http.StatusForbidden,
|
line = s.request(client, "/blocked", http.StatusForbidden,
|
||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionBlockedByBlockRule)
|
||||||
wantRuleIDs(t, line, "noted", "blocked")
|
wantRuleIDs(t, line, "noted", "blocked")
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for
|
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
|
||||||
// seven days, the default.
|
// seven days, the default.
|
||||||
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
line = s.request(client, "/.env", http.StatusTooManyRequests,
|
||||||
|
requestlog.ActionTemporarilyBanned)
|
||||||
wantRuleIDs(t, line, "noted", "probe")
|
wantRuleIDs(t, line, "noted", "probe")
|
||||||
|
|
||||||
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
||||||
@@ -82,7 +83,7 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
// against the rules, and makes the ban permanent.
|
// against the rules, and makes the ban permanent.
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
|
|
||||||
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
if line.BanExpires != permanent {
|
if line.BanExpires != permanent {
|
||||||
@@ -90,7 +91,37 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clk.advance(365 * 24 * time.Hour)
|
clk.advance(365 * 24 * time.Hour)
|
||||||
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
|
||||||
|
})
|
||||||
|
|
||||||
|
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
|
||||||
|
"exploitsignature")
|
||||||
|
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
|
||||||
|
requestlog.ActionBlockedByBlockRule), "exploitsignature")
|
||||||
|
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.request(client, "/blocked", http.StatusForbidden,
|
||||||
|
requestlog.ActionBlockedByBlockRule)
|
||||||
|
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
|
||||||
|
|
||||||
|
line = s.request(client, "/.env", http.StatusForbidden,
|
||||||
|
requestlog.ActionTemporarilyBanned)
|
||||||
|
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
||||||
@@ -101,8 +132,9 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
|||||||
attackBanDuration: "1h",
|
attackBanDuration: "1h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// The first probe bans for SWWAF_ATTACK_BAN_DURATION.
|
// The first probe bans for SWW_ATTACK_BAN_DURATION.
|
||||||
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
line := s.request(client, "/.env", http.StatusForbidden,
|
||||||
|
requestlog.ActionTemporarilyBanned)
|
||||||
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
||||||
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
||||||
}
|
}
|
||||||
@@ -112,7 +144,8 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
|||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
line = s.request(client, "/.env", http.StatusForbidden,
|
||||||
|
requestlog.ActionPermanentlyBanned)
|
||||||
if line.BanExpires != permanent {
|
if line.BanExpires != permanent {
|
||||||
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||||
}
|
}
|
||||||
@@ -122,8 +155,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
@@ -133,14 +166,15 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS is not checked.
|
// A client in SWW_ALLOW_NETS is not checked.
|
||||||
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
// A probe over the rate limit breaks the limit before any rule sees
|
// A probe over the rate limit breaks the limit before any rule sees
|
||||||
// it.
|
// it.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionRateLimited)
|
line = s.request(client, "/.env", http.StatusForbidden,
|
||||||
|
requestlog.ActionClientIsRateLimited)
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||||
@@ -150,7 +184,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
|
|
||||||
// A client the rate limits do not apply to is still checked.
|
// A client the rate limits do not apply to is still checked.
|
||||||
s.get(exempt, http.StatusOK, requestlog.ActionForward)
|
s.get(exempt, http.StatusOK, requestlog.ActionForward)
|
||||||
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
||||||
@@ -162,11 +196,11 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
|
||||||
wantRuleIDs(t, line, "blocked")
|
wantRuleIDs(t, line, "blocked")
|
||||||
|
|
||||||
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
|
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||||
wantRuleIDs(t, line, "probe")
|
wantRuleIDs(t, line, "probe")
|
||||||
|
|
||||||
if line.BanExpires != "" {
|
if line.BanExpires != "" {
|
||||||
@@ -190,24 +224,25 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
|||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, "/blocked", http.StatusForbidden, requestlog.ActionRuleBlocked)
|
s.request(client, "/blocked", http.StatusForbidden,
|
||||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
requestlog.ActionBlockedByBlockRule)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
`sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2)
|
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+
|
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_block_rule",`+
|
||||||
`instance="app",status_class="4xx"}`, 1)
|
`instance="app",status_class="4xx"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 1)
|
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="attack"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRules writes content as a rule file into a new directory, and
|
// writeRules writes content as a rule file into a new directory, and
|
||||||
+29
-21
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -26,19 +26,18 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
|||||||
calls.Add(1)
|
calls.Add(1)
|
||||||
})
|
})
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
// fromKP is in SWWAF_ALLOW_NETS, and in SWWAF_DENY_NETS too, which
|
// fromKP is in SWW_ALLOW_NETS, and in SWW_DENY_NETS too, which
|
||||||
// comes after it.
|
// comes after it.
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
allowNets: "198.51.100.0/24",
|
allowNets: "198.51.100.0/24",
|
||||||
denyNets: fromKP,
|
denyNets: fromKP,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one
|
// Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a
|
||||||
// request a minute refuses the client, and its country is not looked
|
// minute refuses the client, and its country is not looked up.
|
||||||
// up.
|
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{fromKP, http.StatusOK, requestlog.ActionForward},
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
||||||
{fromKP, http.StatusOK, requestlog.ActionForward},
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
||||||
@@ -69,7 +68,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
|
|||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +82,9 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
|
|||||||
})
|
})
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
denyNets: "203.0.113.0/24",
|
denyNets: "203.0.113.0/24",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||||
@@ -93,7 +92,7 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
|
|||||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||||
|
|
||||||
line := out.endedLine(t)
|
line := out.endedLine(t)
|
||||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||||
|
|
||||||
if len(asked()) != 0 {
|
if len(asked()) != 0 {
|
||||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||||
@@ -104,6 +103,15 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||||
|
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
|
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -117,10 +125,10 @@ func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
|
|||||||
// listedAddr's refused requests are not counted, so the first request
|
// listedAddr's refused requests are not counted, so the first request
|
||||||
// from unlistedAddr is within the limit of one a minute.
|
// from unlistedAddr is within the limit of one a minute.
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
|
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
|
||||||
{listedAddr, http.StatusForbidden, requestlog.ActionDenied},
|
{listedAddr, http.StatusForbidden, requestlog.ActionClientDenied},
|
||||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,19 +141,19 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitExemptNets: listedAddr + "," + fromKP,
|
rateLimitExemptNets: listedAddr + "," + fromKP,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// listedAddr's requests are neither refused nor counted, so the first
|
// listedAddr's requests are neither refused nor counted, so the first
|
||||||
// request from unlistedAddr is within the limit of one a minute. The
|
// request from unlistedAddr is within the limit of one a minute.
|
||||||
// country lists still refuse an exempt client.
|
// SWW_TAG_DENY still refuses an exempt client.
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
||||||
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
|
{fromKP, http.StatusForbidden, requestlog.ActionClientDenied},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +165,9 @@ type sentRequest struct {
|
|||||||
action string
|
action string
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantAnswers sends requests to smallwebwaf at addr one after another and
|
// wantAnswers sends requests to sww at addr one after another and
|
||||||
// checks each one's answer and log line. They must be the first requests
|
// checks each one's answer and log line. They must be the first requests
|
||||||
// smallwebwaf is sent, since the log lines are matched to them in order.
|
// sww is sent, since the log lines are matched to them in order.
|
||||||
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
|
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic tag_deny. It reads SWW_TAG_DENY.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
|
||||||
|
// the first such tag, in sorted order.
|
||||||
|
type tagDeny struct{}
|
||||||
|
|
||||||
|
func (tagDeny) name() string { return "tag_deny" }
|
||||||
|
|
||||||
|
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
for _, tag := range rq.line.Tags {
|
||||||
|
if slices.Contains(rq.h.config.TagDeny, tag) {
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionClientDenied,
|
||||||
|
figures: map[string]any{"tag": tag},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package discriminator_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The setting takes a country code in either case; the request's tag is
|
||||||
|
// in capitals.
|
||||||
|
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||||
|
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
|
||||||
|
wantTags(t, line, "asn=64511", "country=KP")
|
||||||
|
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDenyRefusesAnASNTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
|
||||||
|
|
||||||
|
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
||||||
|
"tag_deny", `{"tag": "asn=64511"}`)
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "torexit=" + torList, tagDeny: "torexit", rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{torList: {fromDE}})
|
||||||
|
|
||||||
|
// Each is refused for its tag: none is counted to break the limit of one.
|
||||||
|
for range 3 {
|
||||||
|
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
||||||
|
"tag_deny", `{"tag": "torexit"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const mixedCaseZone = "DNSBL.example"
|
||||||
|
|
||||||
|
// The zone's tag is in lower case, however SWW_DNSBL_ZONES writes it.
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
|
||||||
|
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
|
||||||
|
})
|
||||||
|
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
|
||||||
|
|
||||||
|
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
||||||
|
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||||
|
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
||||||
|
wantTags(t, line, "exploitsignature")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
rateLimitPerMinute: fourAMinute,
|
||||||
|
tagLists: "vpn=" + vpnList + ",tor=" + torList,
|
||||||
|
tagLimitPercent: "vpn:50,tor:25",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
|
||||||
|
|
||||||
|
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
wantPercent(t, "rate_limit_percent",
|
||||||
|
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||||
|
"25 from "+tagLimitPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
bytesLimitPerMinute: twoUploads,
|
||||||
|
tagLists: "vpn=" + vpnList,
|
||||||
|
tagLimitPercent: "vpn:50",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromDE}})
|
||||||
|
|
||||||
|
// The upload's 100 bytes are over half of 199, 99.
|
||||||
|
line := s.uploadFrom(fromDE)
|
||||||
|
if line.LimitHit != minuteBytes {
|
||||||
|
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercent(t, "bytes_limit_percent",
|
||||||
|
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||||
|
"50 from "+tagLimitPercent)
|
||||||
|
|
||||||
|
// fromKP, which the list does not hold, has the whole limit.
|
||||||
|
line = s.uploadFrom(fromKP)
|
||||||
|
if line.LimitHit != "" {
|
||||||
|
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPercent(t, "bytes_limit_percent",
|
||||||
|
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
|
||||||
|
// the reputation lists.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
|
||||||
|
// holds the client, the tag being the list's name. It refuses nothing, and runs
|
||||||
|
// first so that every refusal's log line carries these tags.
|
||||||
|
type tagLists struct{}
|
||||||
|
|
||||||
|
func (tagLists) name() string { return "tag_lists" }
|
||||||
|
|
||||||
|
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
for _, name := range rq.h.lists.TagsOf(rq.client) {
|
||||||
|
rq.addTag(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package discriminator_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
|
||||||
|
const (
|
||||||
|
tagLists = "SWW_TAG_LISTS"
|
||||||
|
vpnList = "https://lists.example/vpn.txt"
|
||||||
|
torList = "https://lists.example/torexit.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wantTags checks the log line's tags.
|
||||||
|
func wantTags(t *testing.T, line logLine, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got, _ := line.fields["tags"].([]any)
|
||||||
|
|
||||||
|
tags := make([]string, 0, len(got))
|
||||||
|
for _, tag := range got {
|
||||||
|
text, _ := tag.(string)
|
||||||
|
tags = append(tags, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(tags, want) {
|
||||||
|
t.Errorf("log line has tags %v, want %v", tags, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{
|
||||||
|
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
|
||||||
|
})
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"asn=64496", "country=DE", "torexit", "vpn=mullvad")
|
||||||
|
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"asn=64511", "country=KP", "torexit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// With no lookup, a client is not placed and no setting gives it a tag.
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
if _, present := line.fields["tags"]; present {
|
||||||
|
t.Errorf("log line has tags %v, want none", line.fields["tags"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "vpn=" + vpnList, allowedCountries: "de",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||||
|
"asn=64511", "country=KP", "vpn")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "vpn=" + vpnList, denyNets: fromKP, rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||||
|
if line.Heuristic != "deny_nets" {
|
||||||
|
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantTags(t, line, "vpn")
|
||||||
|
|
||||||
|
// The second request breaks the limit of one a minute, which bans the client.
|
||||||
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||||
|
|
||||||
|
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||||
|
if line.Heuristic != "active_ban" {
|
||||||
|
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantTags(t, line, "vpn")
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic trap_paths. It reads SWW_TRAP_PATHS.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trapPaths refuses a request for a path in SWW_TRAP_PATHS: its path as a
|
||||||
|
// path rule sees it, without the query, as sent or once percent-decoded, is one
|
||||||
|
// of them. Such a request is a clear sign of attack, as a ban rule's match is:
|
||||||
|
// it bans the client's netblock, or in observe mode raises the alert for the
|
||||||
|
// ban it would have made. The ban's notes give the path as sent, and so does
|
||||||
|
// its figure.
|
||||||
|
type trapPaths struct{}
|
||||||
|
|
||||||
|
func (trapPaths) name() string { return "trap_paths" }
|
||||||
|
|
||||||
|
func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||||
|
path := rules.Path(rq.in)
|
||||||
|
if !slices.Contains(rq.h.config.TrapPaths, path) &&
|
||||||
|
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.attack = true
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: rq.banForAttack(now, bans.Notes{TrapPath: path}),
|
||||||
|
figures: map[string]any{"path": path},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,14 +14,14 @@ import (
|
|||||||
// trapPaths is the setting's name, and trapPathList what the tests set it
|
// trapPaths is the setting's name, and trapPathList what the tests set it
|
||||||
// to.
|
// to.
|
||||||
const (
|
const (
|
||||||
trapPaths = "SWWAF_TRAP_PATHS"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
trapPathList = "/wp-login.php,/xmlrpc.php"
|
trapPathList = "/wp-login.php,/xmlrpc.php"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
|
|
||||||
// A block rule for the same path: the trap path comes first.
|
// A block rule for the same path: the trap path comes first.
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
@@ -38,12 +38,12 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|||||||
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS is not checked.
|
// A client in SWW_ALLOW_NETS is not checked.
|
||||||
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// The query is not part of the path.
|
// The query is not part of the path.
|
||||||
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
|
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
|
||||||
requestlog.ActionBanned)
|
requestlog.ActionTemporarilyBanned)
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
||||||
@@ -77,12 +77,22 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The next request is refused under the ban, and makes it permanent.
|
// The next request is refused under the ban, and makes it permanent.
|
||||||
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
|
||||||
if line.BanExpires != permanent {
|
if line.BanExpires != permanent {
|
||||||
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
|
||||||
|
|
||||||
|
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
|
||||||
|
requestlog.ActionTemporarilyBanned)
|
||||||
|
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
|
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -90,7 +100,8 @@ func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
|
|||||||
|
|
||||||
// %2577 decodes once to %77, not to w.
|
// %2577 decodes once to %77, not to w.
|
||||||
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
|
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
|
||||||
s.request(client, "/%77p-login.php", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/%77p-login.php", http.StatusForbidden,
|
||||||
|
requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
// The ban's notes give the path as the client sent it.
|
// The ban's notes give the path as the client sent it.
|
||||||
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||||
@@ -103,11 +114,12 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
trapPaths: trapPathList,
|
trapPaths: trapPathList,
|
||||||
"SWWAF_RULES_ENABLED": "false",
|
"SWW_RULES_ENABLED": "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/xmlrpc.php", http.StatusForbidden,
|
||||||
|
requestlog.ActionTemporarilyBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
|
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
|
||||||
@@ -119,7 +131,7 @@ func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||||
|
|
||||||
// No ban was made.
|
// No ban was made.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
+18
-8
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
|
|||||||
// FileParams are what OpenFile needs.
|
// FileParams are what OpenFile needs.
|
||||||
type FileParams struct {
|
type FileParams struct {
|
||||||
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||||
// (SWWAF_LOOKUP_DB_PATH).
|
// (SWW_LOOKUP_DB_PATH).
|
||||||
Path string
|
Path string
|
||||||
// Now tells the time, normally time.Now.
|
// Now tells the time, normally time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -51,7 +51,7 @@ type File struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// record is what the lookup database holds about a network, of the fields
|
// record is what the lookup database holds about a network, of the fields
|
||||||
// smallwebwaf reads.
|
// sww reads.
|
||||||
type record struct {
|
type record struct {
|
||||||
ASN string `maxminddb:"asn"`
|
ASN string `maxminddb:"asn"`
|
||||||
ASName string `maxminddb:"as_name"`
|
ASName string `maxminddb:"as_name"`
|
||||||
@@ -133,8 +133,11 @@ func (f *File) Watch(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
f.params.ProcessLog.Error(
|
||||||
"error", err.Error())
|
"cannot watch the lookup database for replacements",
|
||||||
|
"error",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,7 +197,10 @@ func (f *File) readAgain() {
|
|||||||
f.params.Alerts.Raise(alerts.Alert{
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: kept,
|
Reason: kept,
|
||||||
Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
|
Detail: map[string]any{
|
||||||
|
"file": f.params.Path,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
f.params.ProcessLog.Error(kept, "error", err.Error())
|
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||||
|
|
||||||
@@ -212,7 +218,11 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
f.lastRead = f.params.Now()
|
f.lastRead = f.params.Now()
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
|
f.params.ProcessLog.Info(
|
||||||
|
"read the lookup database",
|
||||||
|
"file",
|
||||||
|
f.params.Path,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// read reads the lookup database at path. The whole file is read into
|
// read reads the lookup database at path. The whole file is read into
|
||||||
@@ -221,12 +231,12 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
func read(path string) (*maxminddb.Reader, error) {
|
func read(path string) (*maxminddb.Reader, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := maxminddb.OpenBytes(data)
|
reader, err := maxminddb.OpenBytes(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return reader, nil
|
return reader, nil
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
|||||||
writeFile(t, notDatabase, "not a lookup database\n")
|
writeFile(t, notDatabase, "not a lookup database\n")
|
||||||
|
|
||||||
for path, want := range map[string]string{
|
for path, want := range map[string]string{
|
||||||
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||||
": no such file or directory",
|
": no such file or directory",
|
||||||
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase +
|
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
|
||||||
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||||
} {
|
} {
|
||||||
_, err := OpenFile(FileParams{
|
_, err := OpenFile(FileParams{
|
||||||
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
|
|||||||
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": path,
|
"file": path,
|
||||||
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||||
"error opening database: invalid MaxMind DB file",
|
"error opening database: invalid MaxMind DB file",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package lookup looks up each client's AS number and country, through
|
// Package lookup looks up each client's AS number and country, through
|
||||||
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
||||||
// SWWAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||||
// most 100,000 clients and for 7 days each, and are written to
|
// most 100,000 clients and for 7 days each, and are written to
|
||||||
// lookups.json and read from it by the state package.
|
// lookups.json and read from it by the state package.
|
||||||
package lookup
|
package lookup
|
||||||
@@ -66,7 +66,7 @@ type Params struct {
|
|||||||
URL string
|
URL string
|
||||||
// Timeout is how long a request waits for its client's first answer,
|
// Timeout is how long a request waits for its client's first answer,
|
||||||
// and how long a request to GeoJS may take before it is abandoned
|
// and how long a request to GeoJS may take before it is abandoned
|
||||||
// (SWWAF_LOOKUP_TIMEOUT).
|
// (SWW_LOOKUP_TIMEOUT).
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Wait is true when a setting needs each request's answer before the
|
// Wait is true when a setting needs each request's answer before the
|
||||||
// request goes on. Otherwise no request waits for one.
|
// request goes on. Otherwise no request waits for one.
|
||||||
@@ -198,7 +198,7 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
|||||||
|
|
||||||
answer, found := g.kept(client)
|
answer, found := g.kept(client)
|
||||||
if !found {
|
if !found {
|
||||||
g.metrics.GeoJSUnanswered.Inc()
|
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
w, waiting := g.waiting[client]
|
w, waiting := g.waiting[client]
|
||||||
@@ -296,7 +296,7 @@ func (g *GeoJS) answerOrWait(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if w == nil {
|
if w == nil {
|
||||||
g.metrics.GeoJSUnanswered.Inc()
|
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||||
|
|
||||||
return Answer{}, nil // too many clients wait already
|
return Answer{}, nil // too many clients wait already
|
||||||
}
|
}
|
||||||
@@ -307,7 +307,7 @@ func (g *GeoJS) answerOrWait(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if w.late {
|
if w.late {
|
||||||
g.metrics.GeoJSUnanswered.Inc()
|
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||||
|
|
||||||
return Answer{}, nil
|
return Answer{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
|
|||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
wantCountry(t, g, client, germany)
|
wantCountry(t, g, client, germany)
|
||||||
wantUnanswered(t, m, 0)
|
wantRequestsWithoutGeoJSAnswer(t, m, 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,7 +596,7 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
|||||||
// GeoJS fails, so the first client goes without an answer, and GeoJS
|
// GeoJS fails, so the first client goes without an answer, and GeoJS
|
||||||
// is left alone for a second, which does not pass in this test.
|
// is left alone for a second, which does not pass in this test.
|
||||||
wantCountry(t, g, clients(), "")
|
wantCountry(t, g, clients(), "")
|
||||||
wantUnanswered(t, m, 1)
|
wantRequestsWithoutGeoJSAnswer(t, m, 1)
|
||||||
|
|
||||||
// Meanwhile each new client goes without one at once, while there is
|
// Meanwhile each new client goes without one at once, while there is
|
||||||
// room for it among the 10,000 that may wait.
|
// room for it among the 10,000 that may wait.
|
||||||
@@ -604,11 +604,11 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
|||||||
wantCountry(t, g, clients(), "")
|
wantCountry(t, g, clients(), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
wantUnanswered(t, m, 10000)
|
wantRequestsWithoutGeoJSAnswer(t, m, 10000)
|
||||||
|
|
||||||
// One more, for which there is no room, goes without one too.
|
// One more, for which there is no room, goes without one too.
|
||||||
wantCountry(t, g, clients(), "")
|
wantCountry(t, g, clients(), "")
|
||||||
wantUnanswered(t, m, 10001)
|
wantRequestsWithoutGeoJSAnswer(t, m, 10001)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,12 +841,12 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantUnanswered checks how many requests m counts as having gone without
|
// wantRequestsWithoutGeoJSAnswer checks how many requests m counts as having
|
||||||
// an answer from GeoJS.
|
// gone without an answer from GeoJS.
|
||||||
func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) {
|
func wantRequestsWithoutGeoJSAnswer(t *testing.T, m *metrics.Metrics, want float64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
got := testutil.ToFloat64(m.GeoJSUnanswered)
|
got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer)
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Errorf("%v requests went without an answer, want %v", got, want)
|
t.Errorf("%v requests went without an answer, want %v", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
const fileMode = 0o600
|
const fileMode = 0o600
|
||||||
|
|
||||||
// Network is what a lookup database holds about a netblock, of the fields
|
// Network is what a lookup database holds about a netblock, of the fields
|
||||||
// smallwebwaf reads: its AS number, such as AS64496, the AS's name, and
|
// sww reads: its AS number, such as AS64496, the AS's name, and
|
||||||
// its country, such as DE.
|
// its country, such as DE.
|
||||||
type Network struct {
|
type Network struct {
|
||||||
ASN string
|
ASN string
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type busiest struct {
|
|||||||
requests *prometheus.CounterVec
|
requests *prometheus.CounterVec
|
||||||
requestBytes *prometheus.CounterVec
|
requestBytes *prometheus.CounterVec
|
||||||
responseBytes *prometheus.CounterVec
|
responseBytes *prometheus.CounterVec
|
||||||
// refused are, by country, the requests the country lists refused; nil
|
// refused are, by country, the requests the country list refused; nil
|
||||||
// by AS number.
|
// by AS number.
|
||||||
refused *prometheus.CounterVec
|
refused *prometheus.CounterVec
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ type busiest struct {
|
|||||||
// for the topN busiest countries.
|
// for the topN busiest countries.
|
||||||
func newCountries(topN int) *busiest {
|
func newCountries(topN int) *busiest {
|
||||||
countries := newBusiest(topN, "country", "the client's country")
|
countries := newBusiest(topN, "country", "the client's country")
|
||||||
countries.refused = counterVec("smallwebwaf_country_list_refusals_total",
|
countries.refused = counterVec("sww_country_list_refusals_total",
|
||||||
"Requests the country lists refused, by the client's country.",
|
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
||||||
[]string{"country"})
|
[]string{"country"})
|
||||||
|
|
||||||
return countries
|
return countries
|
||||||
@@ -61,11 +61,11 @@ func newBusiest(topN int, label, description string) *busiest {
|
|||||||
|
|
||||||
return &busiest{
|
return &busiest{
|
||||||
topN: topN,
|
topN: topN,
|
||||||
requests: counterVec("smallwebwaf_"+label+"_requests_total",
|
requests: counterVec("sww_"+label+"_requests_total",
|
||||||
"Requests, by "+description+".", by),
|
"Requests, by "+description+".", by),
|
||||||
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total",
|
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
||||||
"Request body bytes, by "+description+".", by),
|
"Request body bytes, by "+description+".", by),
|
||||||
responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total",
|
responseBytes: counterVec("sww_"+label+"_response_bytes_total",
|
||||||
"Response body bytes, by "+description+".", by),
|
"Response body bytes, by "+description+".", by),
|
||||||
seen: map[string]int64{},
|
seen: map[string]int64{},
|
||||||
top: map[string]bool{},
|
top: map[string]bool{},
|
||||||
@@ -88,7 +88,7 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
||||||
// country, the refusals by the country lists as well.
|
// country, the refusals by the country list as well.
|
||||||
func (b *busiest) vecs() []*prometheus.CounterVec {
|
func (b *busiest) vecs() []*prometheus.CounterVec {
|
||||||
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
||||||
if b.refused != nil {
|
if b.refused != nil {
|
||||||
|
|||||||
+77
-77
@@ -1,4 +1,4 @@
|
|||||||
// Package metrics keeps smallwebwaf's Prometheus metrics, as
|
// Package metrics keeps sww's Prometheus metrics, as
|
||||||
// docs/metrics.md lists them, and serves them in the Prometheus text
|
// docs/metrics.md lists them, and serves them in the Prometheus text
|
||||||
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
||||||
// metric carries a client's address.
|
// metric carries a client's address.
|
||||||
@@ -22,21 +22,21 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
// Metrics are sww's metrics. They are safe for concurrent use.
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
// registry gives every metric registered with it the label instance.
|
// registry gives every metric registered with it the label instance.
|
||||||
registry prometheus.Registerer
|
registry prometheus.Registerer
|
||||||
handler http.Handler
|
handler http.Handler
|
||||||
|
|
||||||
inFlight prometheus.Gauge
|
inFlight prometheus.Gauge
|
||||||
requests *prometheus.CounterVec
|
requests *prometheus.CounterVec
|
||||||
requestBytes *prometheus.CounterVec
|
requestBytes *prometheus.CounterVec
|
||||||
responseBytes *prometheus.CounterVec
|
responseBytes *prometheus.CounterVec
|
||||||
requestDuration prometheus.Histogram
|
requestDuration prometheus.Histogram
|
||||||
upstreamDuration prometheus.Histogram
|
upstreamDuration prometheus.Histogram
|
||||||
rateLimitHits *prometheus.CounterVec
|
rateLimitHits *prometheus.CounterVec
|
||||||
offences *prometheus.CounterVec
|
offences *prometheus.CounterVec
|
||||||
wafMatches *prometheus.CounterVec
|
coreRuleSetMatches *prometheus.CounterVec
|
||||||
// ruleMatches are made by AddRules, and reputationHits by
|
// ruleMatches are made by AddRules, and reputationHits by
|
||||||
// AddReputation.
|
// AddReputation.
|
||||||
ruleMatches *prometheus.CounterVec
|
ruleMatches *prometheus.CounterVec
|
||||||
@@ -45,12 +45,12 @@ type Metrics struct {
|
|||||||
asns *busiest
|
asns *busiest
|
||||||
|
|
||||||
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
||||||
// that failed. GeoJSUnanswered are the requests that needed their
|
// that failed. RequestsWithoutGeoJSAnswer are the requests that needed
|
||||||
// client's answer, for a setting that acts on it, and went on without
|
// their client's answer, for a setting that acts on it, and went on
|
||||||
// it because GeoJS had not given it in time.
|
// without it because GeoJS had not given it in time.
|
||||||
GeoJSRequests prometheus.Counter
|
GeoJSRequests prometheus.Counter
|
||||||
GeoJSFailures prometheus.Counter
|
GeoJSFailures prometheus.Counter
|
||||||
GeoJSUnanswered prometheus.Counter
|
RequestsWithoutGeoJSAnswer prometheus.Counter
|
||||||
|
|
||||||
stateFileWrites *prometheus.CounterVec
|
stateFileWrites *prometheus.CounterVec
|
||||||
stateFileWriteFailures *prometheus.CounterVec
|
stateFileWriteFailures *prometheus.CounterVec
|
||||||
@@ -62,8 +62,8 @@ type Metrics struct {
|
|||||||
|
|
||||||
// New returns the metrics, with the Go runtime's and the process's own.
|
// New returns the metrics, with the Go runtime's and the process's own.
|
||||||
// topN is how many countries and how many AS numbers get series of their
|
// topN is how many countries and how many AS numbers get series of their
|
||||||
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
// own (SWW_METRICS_TOP_N). Every metric carries instanceName
|
||||||
// (SWWAF_INSTANCE_NAME) as its label instance.
|
// (SWW_INSTANCE_NAME) as its label instance.
|
||||||
//
|
//
|
||||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||||
func New(topN int, instanceName string) *Metrics {
|
func New(topN int, instanceName string) *Metrics {
|
||||||
@@ -76,60 +76,60 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
prometheus.Labels{"instance": instanceName}, registry),
|
prometheus.Labels{"instance": instanceName}, registry),
|
||||||
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
||||||
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_requests_in_flight",
|
Name: "sww_requests_in_flight",
|
||||||
Help: "Requests under way.",
|
Help: "Requests under way.",
|
||||||
}),
|
}),
|
||||||
requests: counterVec("smallwebwaf_requests_total",
|
requests: counterVec("sww_requests_total",
|
||||||
"Requests, by the class of their status and their action.", byStatus),
|
"Requests, by the class of their status and their action.", byStatus),
|
||||||
requestBytes: counterVec("smallwebwaf_request_bytes_total",
|
requestBytes: counterVec("sww_request_bytes_total",
|
||||||
"Request body bytes, by the class of the status and the action.",
|
"Request body bytes, by the class of the status and the action.",
|
||||||
byStatus),
|
byStatus),
|
||||||
responseBytes: counterVec("smallwebwaf_response_bytes_total",
|
responseBytes: counterVec("sww_response_bytes_total",
|
||||||
"Response body bytes, by the class of the status and the action.",
|
"Response body bytes, by the class of the status and the action.",
|
||||||
byStatus),
|
byStatus),
|
||||||
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||||
Name: "smallwebwaf_request_duration_seconds",
|
Name: "sww_request_duration_seconds",
|
||||||
Help: "How long requests took, from their arrival to their end.",
|
Help: "How long requests took, from their arrival to their end.",
|
||||||
}),
|
}),
|
||||||
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||||
Name: "smallwebwaf_upstream_duration_seconds",
|
Name: "sww_upstream_duration_seconds",
|
||||||
Help: "How long requests passed to the app took, from then to their end.",
|
Help: "How long requests passed to the app took, from then to their end.",
|
||||||
}),
|
}),
|
||||||
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total",
|
rateLimitHits: counterVec("sww_rate_limit_hits_total",
|
||||||
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
||||||
"its window and its kind, requests, bytes or refusals.",
|
"its window and its kind, requests, bytes or refusals.",
|
||||||
[]string{"window", "kind"}),
|
[]string{"window", "kind"}),
|
||||||
offences: counterVec("smallwebwaf_offences_total",
|
offences: counterVec("sww_offences_total",
|
||||||
"Offences, by kind.", []string{"kind"}),
|
"Offences, by kind.", []string{"kind"}),
|
||||||
wafMatches: counterVec("smallwebwaf_waf_matches_total",
|
coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
|
||||||
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+
|
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
|
||||||
"and the rule's id.", []string{"mode", "rule_id"}),
|
"and the rule's id.", []string{"mode", "rule_id"}),
|
||||||
countries: newCountries(topN),
|
countries: newCountries(topN),
|
||||||
asns: newASNs(topN),
|
asns: newASNs(topN),
|
||||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_requests_total",
|
Name: "sww_geojs_requests_total",
|
||||||
Help: "Requests to GeoJS.",
|
Help: "Requests to GeoJS.",
|
||||||
}),
|
}),
|
||||||
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_failures_total",
|
Name: "sww_geojs_failures_total",
|
||||||
Help: "Requests to GeoJS that failed.",
|
Help: "Requests to GeoJS that failed.",
|
||||||
}),
|
}),
|
||||||
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
RequestsWithoutGeoJSAnswer: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_unanswered_total",
|
Name: "sww_requests_without_geojs_answer_total",
|
||||||
Help: "Requests that needed their client's answer from GeoJS and " +
|
Help: "Requests that needed their client's answer from GeoJS and " +
|
||||||
"went on without it, because GeoJS had not given it in time.",
|
"went on without it, because GeoJS had not given it in time.",
|
||||||
}),
|
}),
|
||||||
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
stateFileWrites: counterVec("sww_state_file_writes_total",
|
||||||
"Writes of each state file.", byFile),
|
"Writes of each state file.", byFile),
|
||||||
stateFileWriteFailures: counterVec("smallwebwaf_state_file_write_failures_total",
|
stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
|
||||||
"Writes of each state file that failed.", byFile),
|
"Writes of each state file that failed.", byFile),
|
||||||
stateFileLastWrite: gaugeVec("smallwebwaf_state_file_last_write_timestamp_seconds",
|
stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
|
||||||
"When each state file was last written, in seconds since 1970.", byFile),
|
"When each state file was last written, in seconds since 1970.", byFile),
|
||||||
stateFileSize: gaugeVec("smallwebwaf_state_file_size_bytes",
|
stateFileSize: gaugeVec("sww_state_file_size_bytes",
|
||||||
"The size of each state file, as it was last written.", byFile),
|
"The size of each state file, as it was last written.", byFile),
|
||||||
stateFileEditsTakenIn: counterVec("smallwebwaf_state_file_edits_taken_in_total",
|
stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
|
||||||
"Edits of each state file taken in while running.", byFile),
|
"Edits of each state file taken in while running.", byFile),
|
||||||
stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total",
|
stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
|
||||||
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
||||||
byFile),
|
byFile),
|
||||||
}
|
}
|
||||||
@@ -139,9 +139,9 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||||
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
||||||
m.requestDuration, m.upstreamDuration,
|
m.requestDuration, m.upstreamDuration,
|
||||||
m.rateLimitHits, m.offences, m.wafMatches,
|
m.rateLimitHits, m.offences, m.coreRuleSetMatches,
|
||||||
m.countries, m.asns,
|
m.countries, m.asns,
|
||||||
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
m.GeoJSRequests, m.GeoJSFailures, m.RequestsWithoutGeoJSAnswer,
|
||||||
m.stateFileWrites, m.stateFileWriteFailures,
|
m.stateFileWrites, m.stateFileWriteFailures,
|
||||||
m.stateFileLastWrite, m.stateFileSize,
|
m.stateFileLastWrite, m.stateFileSize,
|
||||||
m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
|
m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
|
||||||
@@ -161,7 +161,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
||||||
} {
|
} {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_bans_made_total",
|
Name: "sww_bans_made_total",
|
||||||
Help: "Bans made, by cause.",
|
Help: "Bans made, by cause.",
|
||||||
ConstLabels: prometheus.Labels{"cause": cause},
|
ConstLabels: prometheus.Labels{"cause": cause},
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
@@ -171,7 +171,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_active_bans",
|
Name: "sww_active_bans",
|
||||||
Help: "Bans active now, the permanent ones included.",
|
Help: "Bans active now, the permanent ones included.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
active, _ := ledger.Count(now())
|
active, _ := ledger.Count(now())
|
||||||
@@ -179,7 +179,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
return float64(active)
|
return float64(active)
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_permanent_bans",
|
Name: "sww_permanent_bans",
|
||||||
Help: "Permanent bans not lifted.",
|
Help: "Permanent bans not lifted.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
_, permanent := ledger.Count(now())
|
_, permanent := ledger.Count(now())
|
||||||
@@ -187,7 +187,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
return float64(permanent)
|
return float64(permanent)
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_tracked_clients",
|
Name: "sww_tracked_clients",
|
||||||
Help: "Clients in the table of clients.",
|
Help: "Clients in the table of clients.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(limiter.Len())
|
return float64(limiter.Len())
|
||||||
@@ -200,13 +200,13 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
// ruleFiles, read as the metrics are asked for. It is called once, before
|
// ruleFiles, read as the metrics are asked for. It is called once, before
|
||||||
// RuleMatched.
|
// RuleMatched.
|
||||||
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
||||||
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total",
|
m.ruleMatches = counterVec("sww_rule_matches_total",
|
||||||
"Requests that matched a rule of the rule files, by its id and action.",
|
"Requests that matched a rule of the rule files, by its id and action.",
|
||||||
[]string{"rule_id", "action"})
|
[]string{"rule_id", "action"})
|
||||||
|
|
||||||
m.registry.MustRegister(m.ruleMatches,
|
m.registry.MustRegister(m.ruleMatches,
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_rules_loaded",
|
Name: "sww_rules_loaded",
|
||||||
Help: "Rules loaded from the rule files.",
|
Help: "Rules loaded from the rule files.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(ruleFiles.Len())
|
return float64(ruleFiles.Len())
|
||||||
@@ -214,25 +214,25 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddRemoteLog adds the metrics of sending the log lines to
|
// AddRemoteLog adds the metrics of sending the log lines to
|
||||||
// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||||
// lines sent, those dropped, and those waiting in the buffer.
|
// lines sent, those dropped, and those waiting in the buffer.
|
||||||
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_remote_log_lines_sent_total",
|
Name: "sww_remote_log_lines_sent_total",
|
||||||
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.",
|
Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Sent())
|
return float64(remote.Sent())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_remote_log_lines_dropped_total",
|
Name: "sww_remote_log_lines_dropped_total",
|
||||||
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
||||||
"whose sending failed.",
|
"whose sending failed.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Dropped())
|
return float64(remote.Dropped())
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_remote_log_buffer_depth",
|
Name: "sww_remote_log_lines_waiting",
|
||||||
Help: "Log lines in the buffer, waiting to be sent.",
|
Help: "Log lines in the buffer, waiting to be sent.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Depth())
|
return float64(remote.Depth())
|
||||||
@@ -248,13 +248,13 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
|||||||
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_lookup_database_last_read_timestamp_seconds",
|
Name: "sww_lookup_database_last_read_timestamp_seconds",
|
||||||
Help: "When the lookup database in use was read, in seconds since 1970.",
|
Help: "When the lookup database in use was read, in seconds since 1970.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(lastRead().Unix())
|
return float64(lastRead().Unix())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_lookup_database_read_failures_total",
|
Name: "sww_lookup_database_read_failures_total",
|
||||||
Help: "Replacements of the lookup database that could not be read.",
|
Help: "Replacements of the lookup database that could not be read.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(readFailures())
|
return float64(readFailures())
|
||||||
@@ -275,7 +275,7 @@ const sourceLabel = "source"
|
|||||||
// copy in use was fetched, and for a zone, the queries made and those that
|
// copy in use was fetched, and for a zone, the queries made and those that
|
||||||
// failed. It is called once, before ReputationHit.
|
// failed. It is called once, before ReputationHit.
|
||||||
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
||||||
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
|
m.reputationHits = counterVec("sww_reputation_hits_total",
|
||||||
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
||||||
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
||||||
[]string{sourceLabel})
|
[]string{sourceLabel})
|
||||||
@@ -294,7 +294,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
Name: "sww_reputation_last_fetch_timestamp_seconds",
|
||||||
Help: "When the copy of the list in use was fetched, in seconds since " +
|
Help: "When the copy of the list in use was fetched, in seconds since " +
|
||||||
"1970, or 0 while there is none.",
|
"1970, or 0 while there is none.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
@@ -313,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
||||||
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
||||||
// that failed, and how many checks the day's budget has left. It is
|
// that failed, and how many checks the day's budget has left. It is
|
||||||
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set.
|
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
|
||||||
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||||
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
||||||
|
|
||||||
@@ -321,8 +321,8 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
|||||||
m.addReputationFailures(source, abuseIPDB.Failures)
|
m.addReputationFailures(source, abuseIPDB.Failures)
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_reputation_daily_budget_remaining",
|
Name: "sww_reputation_daily_budget_remaining",
|
||||||
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(abuseIPDB.BudgetLeft())
|
return float64(abuseIPDB.BudgetLeft())
|
||||||
@@ -348,29 +348,29 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
|
|||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_sent_total",
|
Name: "sww_alerts_sent_total",
|
||||||
Help: "Alerts the destination took.",
|
Help: "Alerts the destination took.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Counts(name).Sent)
|
return float64(queue.Counts(name).Sent)
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_failed_total",
|
Name: "sww_alert_send_failures_total",
|
||||||
Help: "Requests to the destination that failed.",
|
Help: "Requests to the destination that failed.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Counts(name).Failed)
|
return float64(queue.Counts(name).SendFailures)
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_suppressed_total",
|
Name: "sww_alerts_suppressed_total",
|
||||||
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " +
|
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
|
||||||
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Suppressed())
|
return float64(queue.Suppressed())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_dropped_total",
|
Name: "sww_alerts_dropped_total",
|
||||||
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
||||||
"alerts given up as the destination refused them.",
|
"alerts given up as the destination refused them.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
@@ -424,10 +424,10 @@ func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
|||||||
// them.
|
// them.
|
||||||
func (m *Metrics) Offences(r ratelimit.Request) {
|
func (m *Metrics) Offences(r ratelimit.Request) {
|
||||||
for kind, committed := range map[string]bool{
|
for kind, committed := range map[string]bool{
|
||||||
"limit": r.BrokeLimit,
|
"limit": r.BrokeLimit,
|
||||||
"attack": r.Attack,
|
"attack": r.Attack,
|
||||||
"rule_blocked": r.RuleBlocked,
|
"blocked_by_block_rule": r.BlockedByBlockRule,
|
||||||
"waf_blocked": r.WAFBlocked,
|
"blocked_by_core_rule_set": r.BlockedByCoreRuleSet,
|
||||||
} {
|
} {
|
||||||
if committed {
|
if committed {
|
||||||
m.offences.WithLabelValues(kind).Inc()
|
m.offences.WithLabelValues(kind).Inc()
|
||||||
@@ -441,10 +441,10 @@ func (m *Metrics) RuleMatched(id, action string) {
|
|||||||
m.ruleMatches.WithLabelValues(id, action).Inc()
|
m.ruleMatches.WithLabelValues(id, action).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
// CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
|
||||||
// with SWWAF_WAF_MODE at mode.
|
// with SWW_WAF_MODE at mode.
|
||||||
func (m *Metrics) WAFMatched(mode string, id int) {
|
func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
|
||||||
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateFileWritten counts a write of the state file name, of size bytes,
|
// StateFileWritten counts a write of the state file name, of size bytes,
|
||||||
@@ -467,7 +467,7 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StateFileEditTakenIn counts an admin's edit of the state file name
|
// StateFileEditTakenIn counts an admin's edit of the state file name
|
||||||
// taken in while smallwebwaf runs.
|
// taken in while sww runs.
|
||||||
func (m *Metrics) StateFileEditTakenIn(name string) {
|
func (m *Metrics) StateFileEditTakenIn(name string) {
|
||||||
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
||||||
}
|
}
|
||||||
@@ -506,7 +506,7 @@ func (m *Metrics) count(
|
|||||||
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
||||||
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_reputation_queries_total",
|
Name: "sww_reputation_queries_total",
|
||||||
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
@@ -519,7 +519,7 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in
|
|||||||
// that failed, which count tells.
|
// that failed, which count tells.
|
||||||
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_reputation_failures_total",
|
Name: "sww_reputation_failures_total",
|
||||||
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
||||||
"AbuseIPDB, that failed.",
|
"AbuseIPDB, that failed.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
||||||
// Closed without an answer: refused, and no response.
|
// Closed without an answer: refused, and no response.
|
||||||
{Refused: true, Status: 0},
|
{Refused: true, Status: 0},
|
||||||
// Answered 404 for a path under /_smallwebwaf/: neither forwarded nor
|
// Answered 404 for a path under /_sww/: neither forwarded nor
|
||||||
// refused.
|
// refused.
|
||||||
{Status: 404},
|
{Status: 404},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// and bytes counted over a minute, an hour and a day, as the "Counting
|
// and bytes counted over a minute, an hour and a day, as the "Counting
|
||||||
// method" section of SPEC.md describes, which tell when a request takes
|
// method" section of SPEC.md describes, which tell when a request takes
|
||||||
// the client over a rate limit or a byte limit, and each client's history
|
// the client over a rate limit or a byte limit, and each client's history
|
||||||
// since it was first seen. At most SWWAF_MAX_TRACKED_CLIENTS clients are
|
// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
|
||||||
// kept, in memory, and written to clients.json and read from it by the
|
// kept, in memory, and written to clients.json and read from it by the
|
||||||
// state package.
|
// state package.
|
||||||
package ratelimit
|
package ratelimit
|
||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
KindRequests = "requests"
|
KindRequests = "requests"
|
||||||
// KindBytes is a byte limit, on a client's bytes.
|
// KindBytes is a byte limit, on a client's bytes.
|
||||||
KindBytes = "bytes"
|
KindBytes = "bytes"
|
||||||
// KindRefusals is the error burst, on a client's requests smallwebwaf
|
// KindRefusals is the error burst, on a client's requests sww
|
||||||
// refused after a rule file match or for a missing or wrong token.
|
// refused after a rule file match or for a missing or wrong token.
|
||||||
KindRefusals = "refusals"
|
KindRefusals = "refusals"
|
||||||
)
|
)
|
||||||
@@ -92,9 +92,9 @@ type History struct {
|
|||||||
ASName string `json:"as_name,omitempty"`
|
ASName string `json:"as_name,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||||
// Requests are all the client's requests: Forwarded those servewaf allowed,
|
// Requests are all the client's requests: Forwarded those discriminator allowed,
|
||||||
// Refused those it refused, and neither those for a path under
|
// Refused those it refused, and neither those for a path under
|
||||||
// /_smallwebwaf/.
|
// /_sww/.
|
||||||
Requests int64 `json:"requests"`
|
Requests int64 `json:"requests"`
|
||||||
Forwarded int64 `json:"forwarded"`
|
Forwarded int64 `json:"forwarded"`
|
||||||
Refused int64 `json:"refused"`
|
Refused int64 `json:"refused"`
|
||||||
@@ -122,12 +122,13 @@ type Responses struct {
|
|||||||
type Offences struct {
|
type Offences struct {
|
||||||
// Limit is its requests that broke a rate limit, a byte limit or the
|
// Limit is its requests that broke a rate limit, a byte limit or the
|
||||||
// error burst, Attack those that were a clear sign of attack, a match
|
// error burst, Attack those that were a clear sign of attack, a match
|
||||||
// of a ban rule or a request for a trap path, RuleBlocked those a block
|
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
|
||||||
// rule refused, and WAFBlocked those the Core Rule Set refused.
|
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
|
||||||
Limit int64 `json:"limit"`
|
// refused.
|
||||||
Attack int64 `json:"attack"`
|
Limit int64 `json:"limit"`
|
||||||
RuleBlocked int64 `json:"rule_blocked"`
|
Attack int64 `json:"attack"`
|
||||||
WAFBlocked int64 `json:"waf_blocked"`
|
BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
|
||||||
|
BlockedByCoreRuleSet int64 `json:"blocked_by_core_rule_set"`
|
||||||
// TokenRefused is what a clients.json written by an earlier version gives,
|
// TokenRefused is what a clients.json written by an earlier version gives,
|
||||||
// which must still load. Nothing adds to it.
|
// which must still load. Nothing adds to it.
|
||||||
TokenRefused int64 `json:"token_refused,omitempty"`
|
TokenRefused int64 `json:"token_refused,omitempty"`
|
||||||
@@ -135,8 +136,8 @@ type Offences struct {
|
|||||||
|
|
||||||
// Request is what a client's history keeps of one of its requests.
|
// Request is what a client's history keeps of one of its requests.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
// Forwarded is true for a request servewaf allowed, and Refused for one it
|
// Forwarded is true for a request discriminator allowed, and Refused for one it
|
||||||
// refused. Both are false for a request for a path under /_smallwebwaf/.
|
// refused. Both are false for a request for a path under /_sww/.
|
||||||
Forwarded bool
|
Forwarded bool
|
||||||
Refused bool
|
Refused bool
|
||||||
// Status is what the client was sent, 0 if nothing was.
|
// Status is what the client was sent, 0 if nothing was.
|
||||||
@@ -147,16 +148,16 @@ type Request struct {
|
|||||||
ResponseBytes int64
|
ResponseBytes int64
|
||||||
// BrokeLimit is true for a request that broke a rate limit, a byte
|
// BrokeLimit is true for a request that broke a rate limit, a byte
|
||||||
// limit or the error burst, Attack for one that matched a ban rule or
|
// limit or the error burst, Attack for one that matched a ban rule or
|
||||||
// asked for a trap path, RuleBlocked for one a block rule refused, and
|
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
|
||||||
// WAFBlocked for one the Core Rule Set refused.
|
// and BlockedByCoreRuleSet for one the Core Rule Set refused.
|
||||||
BrokeLimit bool
|
BrokeLimit bool
|
||||||
Attack bool
|
Attack bool
|
||||||
RuleBlocked bool
|
BlockedByBlockRule bool
|
||||||
WAFBlocked bool
|
BlockedByCoreRuleSet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Limiter for limits, with no client counted yet, whose
|
// New returns a Limiter for limits, with no client counted yet, whose
|
||||||
// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past
|
// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
|
||||||
// it, the least recently seen client is dropped, with its history, and
|
// it, the least recently seen client is dropped, with its history, and
|
||||||
// starts afresh if it comes back.
|
// starts afresh if it comes back.
|
||||||
func New(limits Limits, maxClients int) *Limiter {
|
func New(limits Limits, maxClients int) *Limiter {
|
||||||
@@ -258,7 +259,7 @@ func (l *Limiter) CountBytes(
|
|||||||
return l.count(client, now, 0, bytes, percent)
|
return l.count(client, now, 0, bytes, percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountRefusal counts a request from client at now that smallwebwaf
|
// CountRefusal counts a request from client at now that sww
|
||||||
// refused after a rule file or Core Rule Set match or for a missing or
|
// refused after a rule file or Core Rule Set match or for a missing or
|
||||||
// wrong token, and reports whether the client's refusals in the minute
|
// wrong token, and reports whether the client's refusals in the minute
|
||||||
// that ends at now, this one included, are more than threshold, which
|
// that ends at now, this one included, are more than threshold, which
|
||||||
@@ -323,12 +324,12 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
|||||||
h.Offences.Attack++
|
h.Offences.Attack++
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.RuleBlocked {
|
if r.BlockedByBlockRule {
|
||||||
h.Offences.RuleBlocked++
|
h.Offences.BlockedByBlockRule++
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.WAFBlocked {
|
if r.BlockedByCoreRuleSet {
|
||||||
h.Offences.WAFBlocked++
|
h.Offences.BlockedByCoreRuleSet++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
const limit = 3
|
const limit = 3
|
||||||
|
|
||||||
// tableSize is the most clients the tests' tables hold, the default of
|
// tableSize is the most clients the tests' tables hold, the default of
|
||||||
// SWWAF_MAX_TRACKED_CLIENTS.
|
// SWW_MAX_TRACKED_CLIENTS.
|
||||||
const tableSize = 20000
|
const tableSize = 20000
|
||||||
|
|
||||||
// whole is the percentage of each limit a client gets when nothing lowers
|
// whole is the percentage of each limit a client gets when nothing lowers
|
||||||
|
|||||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user