Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5364bf282 | ||
|
|
c66277b111 | ||
|
|
f6435b1a73 | ||
|
|
2349b2a081 | ||
|
|
42e06bf298 | ||
|
|
beb503b233 | ||
|
|
2e2f6f7175 | ||
|
|
7fed6b6d36 | ||
|
|
f8676debda | ||
|
|
a41d61e9ad | ||
|
|
21cac70b50 | ||
|
|
2ab57eb278 | ||
|
|
a63b7e4d2d | ||
|
|
3cb41f27a8 | ||
|
|
84a9190d2b | ||
|
|
6deb9d7581 | ||
|
|
8632da556a | ||
|
|
344ce13c96 | ||
|
|
17c5dd2438 | ||
|
|
4a4874ae2f | ||
|
|
2eeea136e8 | ||
|
|
718a2c94b2 | ||
|
|
fdabf4974b | ||
|
|
a9dc59546b | ||
|
|
1bdf66cd21 | ||
|
|
2ac2b671f6 | ||
|
|
f408f42b28 | ||
|
|
acceecc7f8 |
No files matched your search
+31
-42
@@ -87,7 +87,7 @@ COPY . .
|
|||||||
# .git present, a version that is still empty, dev or unknown fails the
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
# build: git is missing or could not read the checkout. The no_fs_access
|
# build: git is missing or could not read the checkout. The no_fs_access
|
||||||
# tag keeps Coraza from writing the files of a multipart body to the
|
# tag keeps Coraza from writing the files of a multipart body to the
|
||||||
# system's temporary directory, since smallwebwaf writes only to its state
|
# system's temporary directory, since sww writes only to its state
|
||||||
# directory.
|
# directory.
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
@@ -99,7 +99,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
|||||||
fi; \
|
fi; \
|
||||||
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
|
-o /usr/local/bin/sww ./cmd/sww
|
||||||
|
|
||||||
# runsvinit, the image's entrypoint, built at the last commit of its
|
# runsvinit, the image's entrypoint, built at the last commit of its
|
||||||
# archived repository. It has no go.mod, and `go build` of its directory
|
# archived repository. It has no go.mod, and `go build` of its directory
|
||||||
@@ -117,8 +117,9 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
|||||||
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
|
&& CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
|
||||||
-o /usr/local/bin/runsvinit .
|
-o /usr/local/bin/runsvinit .
|
||||||
|
|
||||||
# The image an app's Dockerfile builds FROM, described under "Deployment"
|
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
||||||
# in SPEC.md. It is the last stage, so a plain `docker build .` builds it.
|
# SWW_COMMAND says discriminator, and an app's Dockerfile can build FROM it.
|
||||||
|
# It is the last stage, so a plain `docker build .` builds it.
|
||||||
#
|
#
|
||||||
# ubuntu 26.04, 2026-09-27
|
# ubuntu 26.04, 2026-09-27
|
||||||
FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
|
FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
|
||||||
@@ -132,34 +133,22 @@ RUN mkdir /etc/sysusers.d \
|
|||||||
&& echo 'u _runit-log - "runit svlogd user" /nonexistent /sbin/nologin' \
|
&& echo 'u _runit-log - "runit svlogd user" /nonexistent /sbin/nologin' \
|
||||||
> /etc/sysusers.d/runit.conf
|
> /etc/sysusers.d/runit.conf
|
||||||
|
|
||||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive as it was at
|
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
||||||
# the snapshot moment, which is never earlier than the Ubuntu image above.
|
# them over plain HTTP and checks each one against the archive's signed
|
||||||
# apt checks every package against the snapshot's InRelease files, and
|
# package lists, so no CA certificates are needed.
|
||||||
# this step checks those against the hashes named here, which are those
|
#
|
||||||
# of the amd64 archive: other architectures use Ubuntu's ports archive.
|
# nix-bin depends on xz-utils, whose install links man pages with
|
||||||
# apt also fetches the live archive's InRelease files, which change daily
|
# update-alternatives. The image's dpkg config drops man pages, so each
|
||||||
# and which the install does not use. The snapshot service is HTTPS only
|
# link warns. z-man, read after /etc/dpkg/dpkg.cfg.d/excludes, re-includes
|
||||||
# and this image has no CA certificates yet, so this step uses the Go
|
# the man1 pages for this install alone; the same step deletes them again,
|
||||||
# image's. The snapshot service answers 503 at times; apt retries a
|
# so no man page or package is added.
|
||||||
# failed download, waiting twice as long before each retry, and this
|
RUN echo 'path-include=/usr/share/man/man1/*' > /etc/dpkg/dpkg.cfg.d/z-man \
|
||||||
# step gives it five retries, about half a minute in all, where its
|
&& apt-get update \
|
||||||
# default of three gives up after seven seconds.
|
|
||||||
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 \
|
|
||||||
-o Acquire::Retries=5 \
|
|
||||||
&& 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 \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||||
--snapshot 20261001T000000Z \
|
|
||||||
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
|
||||||
-o Acquire::Retries=5 \
|
|
||||||
ca-certificates nix-bin runit \
|
ca-certificates nix-bin runit \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/* /etc/dpkg/dpkg.cfg.d/z-man \
|
||||||
|
&& find /usr/share/man/man1 ! -type d -delete \
|
||||||
|
&& find /etc/alternatives -type l -xtype l -delete
|
||||||
|
|
||||||
# Nix run by root expects a group of build users, which nix-bin does not
|
# Nix run by root expects a group of build users, which nix-bin does not
|
||||||
# create; with the setting empty, root's builds run without them.
|
# create; with the setting empty, root's builds run without them.
|
||||||
@@ -186,27 +175,27 @@ RUN curl -fsSL -o /tmp/nixexprs.tar.xz \
|
|||||||
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
||||||
|
|
||||||
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
||||||
COPY --from=builder /usr/local/bin/smallwebwaf /usr/local/bin/smallwebwaf
|
COPY --from=builder /usr/local/bin/sww /usr/local/bin/sww
|
||||||
|
|
||||||
# 65532 is above the uids Ubuntu keeps for system users, which end at
|
# 65532 is above the uids Ubuntu keeps for system users, which end at
|
||||||
# 999; useradd warns about it unless --key raises that end for this call.
|
# 999; useradd warns about it unless --key raises that end for this call.
|
||||||
RUN groupadd --system --gid 65532 smallwebwaf \
|
RUN groupadd --system --gid 65532 sww \
|
||||||
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
||||||
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \
|
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
||||||
smallwebwaf
|
sww
|
||||||
|
|
||||||
# The state files' directory, SWWAF_STATE_DIR by default, where a volume
|
# The state files' directory, SWW_STATE_DIR by default, where a volume
|
||||||
# is mounted to keep them across deploys. The run script gives it to the
|
# is mounted to keep them across deploys. The run script gives it to the
|
||||||
# smallwebwaf user at each start.
|
# sww user at each start of discriminator.
|
||||||
RUN mkdir /var/lib/smallwebwaf
|
RUN mkdir /var/lib/sww
|
||||||
|
|
||||||
# The default rule file, in SWWAF_RULES_DIR by default, where an app's
|
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
||||||
# Dockerfile can copy rule files of its own beside it.
|
# built FROM this image can copy rule files of its own beside it.
|
||||||
COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules
|
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
||||||
|
|
||||||
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
||||||
# looks too.
|
# looks too.
|
||||||
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
|
COPY --chmod=755 share/sww.run /etc/service/sww/run
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
@@ -214,6 +203,6 @@ EXPOSE 8080
|
|||||||
# check runs every second from the start until it first passes, for up
|
# check runs every second from the start until it first passes, for up
|
||||||
# to a minute, and every 30 seconds after that.
|
# to a minute, and every 30 seconds after that.
|
||||||
HEALTHCHECK --start-period=1m --start-interval=1s \
|
HEALTHCHECK --start-period=1m --start-interval=1s \
|
||||||
CMD ["/usr/local/bin/smallwebwaf", "healthcheck"]
|
CMD ["/usr/local/bin/sww", "healthcheck"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
||||||
+3
-3
@@ -153,15 +153,15 @@ Closest existing options, and why each still falls short:
|
|||||||
- What it is: a single-binary reverse proxy that makes browsers solve a
|
- What it is: a single-binary reverse proxy that makes browsers solve a
|
||||||
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
||||||
scrapers, which is likely a large share of unwanted traffic on a public
|
scrapers, which is likely a large share of unwanted traffic on a public
|
||||||
gitea.
|
code forge.
|
||||||
- Covers: R10 well (one container, env vars for listener, target,
|
- Covers: R10 well (one container, env vars for listener, target,
|
||||||
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
||||||
IP ranges, and with the vendor's hosted data service also AS number and
|
IP ranges, and with the vendor's hosted data service also AS number and
|
||||||
country, and can weigh a request toward a harder challenge.
|
country, and can weigh a request toward a harder challenge.
|
||||||
- Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env
|
- Misses: R1, R3, R4, R5, R6, R9. Bot policy needs a YAML file, not env
|
||||||
vars. AS number and country matching depend on the vendor's hosted
|
vars. AS number and country matching depend on the vendor's hosted
|
||||||
service. Breaks non-browser clients unless paths are exempted; for gitea,
|
service. Breaks non-browser clients unless paths are exempted; for a code
|
||||||
git-over-HTTP and API paths must be allowed through by rule.
|
forge, git-over-HTTP and API paths must be allowed through by rule.
|
||||||
- Maturity: very active, widely deployed on code forges since 2025.
|
- Maturity: very active, widely deployed on code forges since 2025.
|
||||||
- Value here: complementary. It can be chained (traefik, then the sidecar,
|
- Value here: complementary. It can be chained (traefik, then the sidecar,
|
||||||
then Anubis, then the app) if challenge pages are wanted.
|
then Anubis, then the app) if challenge pages are wanted.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
|
||||||
run example-app gitea-requests
|
hooks build run example-app
|
||||||
|
|
||||||
# Makefile targets are thin shims; the implementations live in script/
|
# Makefile targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||||
# which test checks. build and run are for working on the code by hand;
|
# which test checks. build and run are for working on the code by hand;
|
||||||
# example-app checks the image with an app built on it; gitea-requests
|
# example-app checks the image with an app built on it; publish builds
|
||||||
# reports the gitea requests the Core Rule Set refuses at the defaults.
|
# the image on the Docker host and pushes it to the registry.
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -35,6 +35,9 @@ check:
|
|||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
|
publish:
|
||||||
|
@script/publish
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
@@ -46,6 +49,3 @@ run:
|
|||||||
|
|
||||||
example-app:
|
example-app:
|
||||||
@script/example-app
|
@script/example-app
|
||||||
|
|
||||||
gitea-requests:
|
|
||||||
@script/gitea-requests
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// 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))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Command sww is a web application firewall. `sww rproxy` is the reverse
|
||||||
|
// proxy in front of one app, which asks discriminator about each request.
|
||||||
|
// `sww discriminator` decides, for many sites, and keeps what lasts. `sww
|
||||||
|
// healthcheck` is the container's health check. Everything it does is in
|
||||||
|
// internal/sww.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the version of the binary, set when it is built with
|
||||||
|
// -ldflags "-X main.Version=...".
|
||||||
|
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(sww.Main(Version))
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
# A real app names the smallwebwaf image by digest. This one takes the
|
# A real app names the smallwebwaf image by digest. This one takes the
|
||||||
# image script/example-app has just built, or else the one `make docker`
|
# image script/example-app has just built, or else the one `make docker`
|
||||||
# builds.
|
# builds.
|
||||||
ARG SMALLWEBWAF_IMAGE=smallwebwaf
|
ARG SWW_IMAGE=smallwebwaf
|
||||||
FROM ${SMALLWEBWAF_IMAGE}
|
FROM ${SWW_IMAGE}
|
||||||
|
|
||||||
# Packages the app needs, from the nixpkgs in the image.
|
# Packages the app needs, from the nixpkgs in the image.
|
||||||
RUN nix-env -iA nixpkgs.busybox
|
RUN nix-env -iA nixpkgs.busybox
|
||||||
|
|||||||
+35
-23
@@ -1,48 +1,60 @@
|
|||||||
# Admin endpoints
|
# Admin endpoints
|
||||||
|
|
||||||
The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
|
The endpoints under `/_sww/`, what each takes and what each answers.
|
||||||
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says which checks they go through
|
`sww discriminator` answers them at its own address, on the private network.
|
||||||
and how their tokens work.
|
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||||
|
|
||||||
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
|
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
||||||
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
|
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
|
||||||
[`metrics.md`](metrics.md)).
|
[`metrics.md`](metrics.md)).
|
||||||
- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json`
|
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
||||||
lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below.
|
Needs `SWW_ADMIN_TOKEN`, as do the three below.
|
||||||
- `POST /_smallwebwaf/bans`: bans a netblock. The body is a JSON object of
|
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
||||||
`netblock`, `duration` and, if you like, `reason`. `netblock` is a netblock
|
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
||||||
such as `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
||||||
(`SWWAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
||||||
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
||||||
wrong:
|
wrong:
|
||||||
- one that is not such an object, or has another field;
|
- one that is not such an object, or has another field;
|
||||||
- one longer than 4 KiB;
|
- one longer than 4 KiB;
|
||||||
- one that names an IPv4-mapped netblock, or an address with a zone.
|
- one that names an IPv4-mapped netblock, or an address with a zone.
|
||||||
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the
|
- `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
|
||||||
address `<client>`. It answers `404` when none is active.
|
`<client>`. It answers `404` when none is active.
|
||||||
- `GET /_smallwebwaf/clients/<ip>`: what `smallwebwaf` knows of the client.
|
- `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
|
||||||
Under `client`, its counters and history as `clients.json` holds them, or
|
`client`, its counters and history as `clients.json` holds them, or `null`
|
||||||
`null` when `clients.json` does not hold it. Under `bans`, every ban covering
|
when `clients.json` does not hold it. Under `bans`, every ban covering the
|
||||||
the address, with its notes.
|
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
|
## Examples
|
||||||
|
|
||||||
With `SWWAF_ADMIN_TOKEN` set, and the token in `$TOKEN`:
|
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
|
||||||
|
as `discriminator:8080` on its network:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Every ban.
|
# Every ban.
|
||||||
curl -H "Authorization: Bearer $TOKEN" https://app.example/_smallwebwaf/bans
|
curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans
|
||||||
|
|
||||||
# Ban 203.0.113.0/24 for seven days.
|
# Ban 203.0.113.0/24 for seven days.
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
||||||
https://app.example/_smallwebwaf/bans
|
http://discriminator:8080/_sww/bans
|
||||||
|
|
||||||
# Lift the bans on 203.0.113.9.
|
# Lift the bans on 203.0.113.9.
|
||||||
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
||||||
https://app.example/_smallwebwaf/bans/203.0.113.9
|
http://discriminator:8080/_sww/bans/203.0.113.9
|
||||||
|
|
||||||
# What smallwebwaf knows of 203.0.113.9.
|
# What discriminator knows of 203.0.113.9.
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
https://app.example/_smallwebwaf/clients/203.0.113.9
|
http://discriminator:8080/_sww/clients/203.0.113.9
|
||||||
```
|
```
|
||||||
+27
-20
@@ -6,7 +6,7 @@ back or dropped.
|
|||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
- `ban`: a ban `smallwebwaf` made.
|
- `ban`: a ban `discriminator` made.
|
||||||
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
||||||
a request.
|
a request.
|
||||||
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
||||||
@@ -34,35 +34,36 @@ on one line:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"instance": "fsn1app1/gitea",
|
"instance": "fsn1/discriminator",
|
||||||
"time": "2026-10-06T12:00:00.123461Z",
|
"time": "2026-10-06T12:00:50.123461Z",
|
||||||
"event": "ban",
|
"event": "ban",
|
||||||
|
"host": "app.example",
|
||||||
"client": "203.0.113.9",
|
"client": "203.0.113.9",
|
||||||
"netblock": "203.0.113.9/32",
|
"netblock": "203.0.113.9/32",
|
||||||
"asn": "",
|
"asn": "",
|
||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"reason": "requests per minute over the limit of 1000",
|
"reason": "requests per minute over the limit of 500",
|
||||||
"detail": {
|
"detail": {
|
||||||
"ban_expires": "2026-10-06T13:00:00.123Z",
|
"ban_expires": "2026-10-06T13:00:50.123Z",
|
||||||
"cause": "limit",
|
"cause": "limit",
|
||||||
"notes": {
|
"notes": {
|
||||||
"asn": "",
|
"asn": "",
|
||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"kind": "requests",
|
"kind": "requests",
|
||||||
"limit": 1000,
|
"limit": 500,
|
||||||
"window": "minute",
|
"window": "minute",
|
||||||
"count": 1001,
|
"count": 501,
|
||||||
"request": {
|
"request": {
|
||||||
"time": "2026-10-06T12:00:00.123456789Z",
|
"time": "2026-10-06T12:00:50.123456789Z",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"host": "app.example",
|
"host": "app.example",
|
||||||
"path": "/owner/repo/commits/branch/main?page=812",
|
"path": "/owner/repo/commits/branch/main?page=501",
|
||||||
"status": 403,
|
"status": 403,
|
||||||
"user_agent": "scraper/1.0"
|
"user_agent": "scraper/1.0"
|
||||||
},
|
},
|
||||||
"requests": 5210,
|
"requests": 501,
|
||||||
"refused": 0,
|
"refused": 0,
|
||||||
"earlier_bans": {
|
"earlier_bans": {
|
||||||
"limit": 0,
|
"limit": 0,
|
||||||
@@ -76,19 +77,23 @@ on one line:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||||
one of the events above.
|
one of the events above.
|
||||||
|
- `host`: the site of the request that raised the alert, since one
|
||||||
|
`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
|
- `client`: the address whose request raised the alert. Empty for
|
||||||
`source_failure` and `file_error`.
|
`source_failure`, `file_error` and `summary`.
|
||||||
- `netblock`: empty for `source_failure` and `file_error`; otherwise one of:
|
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
||||||
|
one of:
|
||||||
- the ban's netblock;
|
- the ban's netblock;
|
||||||
- the netblock an anomaly counted;
|
- 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
|
- for the other events, the client as rate limiting counts it: an IPv4
|
||||||
address as a /32, or an IPv6 group.
|
address as a /32, or an IPv6 group.
|
||||||
- `asn`, `as_name` and `country`: the client's, as known when the alert was
|
- `asn`, `as_name` and `country`: the client's, as known when the alert was
|
||||||
raised. They are empty when GeoJS had not answered yet.
|
raised. They are empty when GeoJS had not answered yet.
|
||||||
- `reason`: a short sentence, such as
|
- `reason`: a short sentence, such as
|
||||||
`requests per minute over the limit of 1000` or `listed by a blocklist`.
|
`requests per minute over the limit of 500` or `listed by a blocklist`.
|
||||||
- `detail`: what is particular to the event:
|
- `detail`: what is particular to the event:
|
||||||
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
|
- for a ban, its `cause`, `ban_expires` and `notes` (the notes are listed
|
||||||
under `bans.json` in [`state-files.md`](state-files.md));
|
under `bans.json` in [`state-files.md`](state-files.md));
|
||||||
@@ -97,6 +102,8 @@ on one line:
|
|||||||
- for a Core Rule Set match, the `rule_ids`, `score`, `method` and `path`;
|
- for a Core Rule Set match, the `rule_ids`, `score`, `method` and `path`;
|
||||||
- for a source failure, the `source` and `error`;
|
- for a source failure, the `source` and `error`;
|
||||||
- for a file error, the `file` 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`.
|
- `mode`, when the request was not refused: `detect` or `observe`.
|
||||||
- `suppressed_repeats`: how many repeats the cooldown held back before this
|
- `suppressed_repeats`: how many repeats the cooldown held back before this
|
||||||
alert.
|
alert.
|
||||||
@@ -104,10 +111,10 @@ on one line:
|
|||||||
## Slack and ntfy
|
## Slack and ntfy
|
||||||
|
|
||||||
Slack and ntfy get a message with a title and a text. The title is the instance
|
Slack and ntfy get a message with a title and a text. The title is the instance
|
||||||
and the event, such as `fsn1app1/gitea: ban`. The text is the `reason`, then one
|
and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
|
||||||
line for each of these that the alert has:
|
one line for each of these that the alert has:
|
||||||
|
|
||||||
- the client, the netblock and the country;
|
- the host, the client, the netblock and the country;
|
||||||
- the file, the source, the error and the mode from `detail`;
|
- the file, the source, the error and the mode from `detail`;
|
||||||
- the suppressed repeats, if any.
|
- the suppressed repeats, if any.
|
||||||
|
|
||||||
@@ -115,8 +122,8 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
|
|||||||
escaped. ntfy gets the text as the message, with these headers:
|
escaped. ntfy gets the text as the message, with these headers:
|
||||||
|
|
||||||
- `Title`: the title;
|
- `Title`: the title;
|
||||||
- `Authorization`: the token, as `Bearer <token>`, while
|
- `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
|
||||||
`SWWAF_ALERT_NTFY_TOKEN` is set;
|
is set;
|
||||||
- `Priority` and `Tags`: by event, as listed below.
|
- `Priority` and `Tags`: by event, as listed below.
|
||||||
|
|
||||||
The priority and tag by event:
|
The priority and tag by event:
|
||||||
|
|||||||
-215
@@ -1,215 +0,0 @@
|
|||||||
# smallwebwaf in front of gitea
|
|
||||||
|
|
||||||
These notes record what `smallwebwaf` does in front of gitea at the default
|
|
||||||
settings. They are observations, not requirements; the requirements are in
|
|
||||||
[`SPEC.md`](../SPEC.md). `script/gitea-requests` runs gitea behind the image and
|
|
||||||
sends through it a sample of gitea's ordinary use and the requests named below.
|
|
||||||
It reports each request the Core Rule Set refuses, so that these notes can be
|
|
||||||
checked again when gitea or the Core Rule Set changes version.
|
|
||||||
|
|
||||||
## What the defaults cover
|
|
||||||
|
|
||||||
- SSH access to gitea does not pass through `smallwebwaf` and is not protected
|
|
||||||
by it.
|
|
||||||
- A clone is a response. It fits the defaults of 30 minutes and 5 GiB for all
|
|
||||||
but the largest repositories and slowest links.
|
|
||||||
- A push is a request, and so is every other upload:
|
|
||||||
- a git push;
|
|
||||||
- an LFS object;
|
|
||||||
- a container image layer;
|
|
||||||
- a package file;
|
|
||||||
- a release attachment.
|
|
||||||
|
|
||||||
At the defaults, an upload larger than 100 MiB or taking more than 60
|
|
||||||
seconds is cut off ("Size and time limits" in `SPEC.md`). A gitea that takes
|
|
||||||
large uploads needs `SWWAF_REQUEST_MAX_BYTES`,
|
|
||||||
`SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` raised
|
|
||||||
to fit.
|
|
||||||
|
|
||||||
- Archive download and blame or history pages are what scrapers hammer. The
|
|
||||||
request limits do most of the work there.
|
|
||||||
- An Actions runner that reaches gitea through `smallwebwaf` sends requests all
|
|
||||||
day. One older than version 0.4 (April 2026) asks for work every 2 seconds,
|
|
||||||
43,200 requests a day, and reports a running job's log and state every second.
|
|
||||||
So it passes the day limit of 50,000 after about an hour and a quarter of
|
|
||||||
jobs. It is then banned, the job it is running fails, and each repeat within a
|
|
||||||
day makes the ban longer. Newer runners ask less often, but a busy one, or
|
|
||||||
several on one address, can still pass it. Put the runners' addresses in
|
|
||||||
`SWWAF_RATE_LIMIT_EXEMPT_NETS`.
|
|
||||||
|
|
||||||
## What the Core Rule Set lets through
|
|
||||||
|
|
||||||
At the defaults, the Core Rule Set lets gitea's ordinary use through, apart from
|
|
||||||
the refusals in the next section:
|
|
||||||
|
|
||||||
- browsing and views of files in a repository, with their history, blame and the
|
|
||||||
file tree;
|
|
||||||
- diffs, including their hidden lines and large files, and pull request review;
|
|
||||||
- git's clone, fetch and push over HTTP;
|
|
||||||
- signing in, including the return to the page a visitor came from, and sign-in
|
|
||||||
with Git Credential Manager, git-credential-oauth or tea;
|
|
||||||
- the API's calls for a file and its commits;
|
|
||||||
- pushing and pulling container images and packages;
|
|
||||||
- Actions runners, and artifacts uploaded with `actions/upload-artifact@v4`;
|
|
||||||
- posting issues, pull requests, comments, wiki pages and files saved in the web
|
|
||||||
editor, code included, since no body is read;
|
|
||||||
- the page gitea shows after a change, with its message naming what was done.
|
|
||||||
|
|
||||||
## What the Core Rule Set still refuses
|
|
||||||
|
|
||||||
Each refusal answers only that request ("Bans" in `SPEC.md` says what it counts
|
|
||||||
toward). The request log names the rule in `waf_rule_ids`, which
|
|
||||||
`SWWAF_WAF_DISABLED_RULES` can switch off.
|
|
||||||
|
|
||||||
### A query string that reads as an attack
|
|
||||||
|
|
||||||
Most often this is a search. A search term is a query parameter, which the Core
|
|
||||||
Rule Set checks against its lists of system files and commands (see the fourth
|
|
||||||
change under "Attack detection" in `SPEC.md`). A release attachment uploaded
|
|
||||||
through the API as `docker-compose.yml` is refused the same way. Examples:
|
|
||||||
|
|
||||||
- a name on those lists, such as `package.json`, `.gitignore` or
|
|
||||||
`docker-compose.yml`;
|
|
||||||
- text that starts with a command name, such as `python3` or `ssh key`, or has
|
|
||||||
one right after a `/` in its first word, such as `feat/docker-support`;
|
|
||||||
- a shell command with its options or a system path (`ls -la`, `sed -i`,
|
|
||||||
`/bin/sh`), or a command in backticks;
|
|
||||||
- script code (`fetch(`, `${VAR}`, `process.env`), HTML (`<img src=`) or an SQL
|
|
||||||
statement (`SELECT * FROM users WHERE`);
|
|
||||||
- a URL naming an IP address or `localhost`.
|
|
||||||
|
|
||||||
### A branch, tag or file name that starts with a command name
|
|
||||||
|
|
||||||
The Core Rule Set's list of commands (932260) holds names such as `docker-`,
|
|
||||||
`python3`, `ansible`, `base64` and `whoami`. A query parameter holding a branch,
|
|
||||||
tag or file name that starts with one is refused. So is one that has such a name
|
|
||||||
right after a `/`, such as `feat/docker-support`, `fix/docker-build` or
|
|
||||||
`renovate/docker-build-push-action-6.x`. gitea's own pages send the name in a
|
|
||||||
query parameter that 930120, 932160 and 932260 still check:
|
|
||||||
|
|
||||||
- `refSubUrl`, the branch or tag of a directory listing. A listing sends it when
|
|
||||||
it asks for its entries' last commits in a second request. It does so whenever
|
|
||||||
gitea takes more than a second to work them out;
|
|
||||||
- `name`, when a branch is deleted or restored on the branches page;
|
|
||||||
- `tag`, when a release is started from a tag;
|
|
||||||
- `template`, the file of an issue template, when an issue is opened from it;
|
|
||||||
- `rule_name`, when a branch protection rule is opened for editing in the
|
|
||||||
repository's settings or has just been saved.
|
|
||||||
|
|
||||||
What a visitor sees, for a branch named `docker-build`:
|
|
||||||
|
|
||||||
- A directory listing that makes that second request leaves those last commits
|
|
||||||
out and shows an error.
|
|
||||||
- The branches page shows an error instead of deleting or restoring the branch.
|
|
||||||
- A release started from such a tag gets the `403` answer of `smallwebwaf` in
|
|
||||||
place of its form. So does an issue opened from such a template, and such a
|
|
||||||
rule opened for editing.
|
|
||||||
- Saving such a rule stores it. gitea then returns to the branch settings page,
|
|
||||||
which gets the `403` answer, so the save looks as if it failed.
|
|
||||||
|
|
||||||
### A file name in a query parameter that trips the code rules
|
|
||||||
|
|
||||||
The Core Rule Set's rules for Java, script, PowerShell and SQL find what they
|
|
||||||
look for in some file names. This holds too in the ten query parameters that
|
|
||||||
930120, 932160 and 932260 do not check (see the fourth change under "Attack
|
|
||||||
detection" in `SPEC.md`). Examples:
|
|
||||||
|
|
||||||
- `runtime` in OpenJDK's `src/java.base/share/classes/java/lang/Runtime.java`
|
|
||||||
(944110);
|
|
||||||
- a Java class, as in `java.lang.String.md` (944130);
|
|
||||||
- `document.write.js` (941180);
|
|
||||||
- `process.env.d.ts` (934100);
|
|
||||||
- `__proto__` (934130);
|
|
||||||
- the PowerShell command `Install-Script`, as in `install-script.sh` (932120);
|
|
||||||
- a database's name, as in PostgreSQL's `information_schema.sql` (942140).
|
|
||||||
|
|
||||||
For such a file, these requests are refused:
|
|
||||||
|
|
||||||
- the API's list of its commits (`path`);
|
|
||||||
- the API's call for the contents of files that names it in its query (`body`);
|
|
||||||
- the commit graph of that file alone (`file`);
|
|
||||||
- the hidden lines of its diff (`path`), which stay hidden;
|
|
||||||
- the sign-in page, for a visitor who opens a page of the file that needs
|
|
||||||
signing in, such as its edit page (`redirect_to`). The visitor can still sign
|
|
||||||
in by opening the sign-in page itself.
|
|
||||||
|
|
||||||
### A path
|
|
||||||
|
|
||||||
- A file name ending in `~`, or an `.xhtml` file whose path holds a space.
|
|
||||||
- A path that holds what the Core Rule Set takes for a Java class (944130), as
|
|
||||||
`java.lang.String.md` and
|
|
||||||
`META-INF/services/org.apache.commons.logging.LogFactory` do.
|
|
||||||
- A path that holds what it takes for Node.js code (934100), as
|
|
||||||
`process.env.d.ts` does.
|
|
||||||
- A path with a directory named `...` (930100, 930110), such as `.../notes.txt`.
|
|
||||||
|
|
||||||
For such a file, these are refused:
|
|
||||||
|
|
||||||
- the page of the file;
|
|
||||||
- its raw file;
|
|
||||||
- its history and its blame;
|
|
||||||
- its edit page.
|
|
||||||
|
|
||||||
The directory that holds it lists it, and a clone carries it.
|
|
||||||
|
|
||||||
### A container image push that reuses a layer
|
|
||||||
|
|
||||||
A push can reuse a layer of an image docker has pushed to that gitea before.
|
|
||||||
docker then asks gitea to take the layer from that image, naming it in the query
|
|
||||||
parameter `from`, which 932260 checks. The request is refused when the image's
|
|
||||||
owner starts with an entry on the list of commands. It is refused too when the
|
|
||||||
image's name after the owner starts with one. Examples are `owner/python3-app`,
|
|
||||||
`owner/docker-base`, and any image of an owner named `ansible` or `docker-team`.
|
|
||||||
|
|
||||||
What happens next depends on docker's image store. A docker that keeps its
|
|
||||||
images in containerd's image store fails the push, and fails each new try the
|
|
||||||
same way. A new install of Docker Engine 29 does this. A docker that keeps them
|
|
||||||
in its own older image store uploads the layer instead. The push then succeeds
|
|
||||||
after one refusal for each layer it reuses, and each of those refusals counts
|
|
||||||
toward the error burst ("Attack detection" in `SPEC.md`).
|
|
||||||
|
|
||||||
### Artifact uploads from `actions/upload-artifact@v3`
|
|
||||||
|
|
||||||
This action sends the header `Content-Range`, which the Core Rule Set refuses
|
|
||||||
(920450). The action sends two files at a time, does not retry a `403`, and
|
|
||||||
sends no further file once one is refused. So an upload is refused once or
|
|
||||||
twice, however many files it holds, and the step fails. One upload does not ban
|
|
||||||
the runner. The error burst ("Attack detection" in `SPEC.md`) bans it after more
|
|
||||||
than 30 such refusals from its address in a minute, as when many jobs upload at
|
|
||||||
once. `actions/upload-artifact@v4` does not send the header.
|
|
||||||
|
|
||||||
## What body inspection refuses
|
|
||||||
|
|
||||||
With `SWWAF_WAF_BODY_LIMIT` set, the Core Rule Set reads request bodies too. In
|
|
||||||
front of gitea it then refuses these, until their rule ids, named in the request
|
|
||||||
log, are added to `SWWAF_WAF_DISABLED_RULES`:
|
|
||||||
|
|
||||||
- issue and comment text, wiki pages and files saved in the web editor that hold
|
|
||||||
shell commands or code (932125, 932235, 932250 and others);
|
|
||||||
- package descriptions that show code;
|
|
||||||
- PyPI uploads (922130);
|
|
||||||
- attachments named like `debug.log` or `config.yml` (932180).
|
|
||||||
|
|
||||||
## What `script/gitea-requests` sends
|
|
||||||
|
|
||||||
Of the requests above, the script sends:
|
|
||||||
|
|
||||||
- for each file named under "A file name in a query parameter that trips the
|
|
||||||
code rules", the API's list of its commits;
|
|
||||||
- for `Runtime.java` alone, the commit graph of that file, the hidden lines of
|
|
||||||
its diff, and the API's call for the contents of files that names it;
|
|
||||||
- the sign-in page for a visitor who opens the edit page of `document.write.js`;
|
|
||||||
- the pages of `java.lang.String.md`,
|
|
||||||
`META-INF/services/org.apache.commons.logging.LogFactory`, `process.env.d.ts`
|
|
||||||
and `.../notes.txt`;
|
|
||||||
- one container image push that reuses a layer, which it does not try again.
|
|
||||||
|
|
||||||
It does not send the raw file, history, blame or edit page of those four files,
|
|
||||||
or the pages of the directories `META-INF/services` and `...`. It also sends a
|
|
||||||
short sample of gitea's ordinary use:
|
|
||||||
|
|
||||||
- signing in;
|
|
||||||
- pages of files and commits, and a diff;
|
|
||||||
- the API;
|
|
||||||
- a push, and clone and fetch over HTTP;
|
|
||||||
- two container image pushes.
|
|
||||||
+12
-14
@@ -1,37 +1,35 @@
|
|||||||
# What the image holds
|
# What the image holds
|
||||||
|
|
||||||
The contents of the `smallwebwaf` image, and how each part is pinned.
|
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
|
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
||||||
is pinned that way.
|
is pinned that way.
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- Ubuntu 26.04 LTS, pinned by digest.
|
- Ubuntu 26.04 LTS, pinned by digest.
|
||||||
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu, from the snapshot of its
|
- `ca-certificates`, `nix-bin` and `runit` from Ubuntu's archive.
|
||||||
archive at `20261001T000000Z`.
|
|
||||||
- Nix, with nixpkgs `nixos-26.05.11045.774debe7a0d1` set up for root as
|
- 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
|
`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`.
|
Nix profile, `/nix/var/nix/profiles/default/bin`, last on the `PATH`.
|
||||||
Unpacked, nixpkgs takes about 500 MiB.
|
Unpacked, nixpkgs takes about 500 MiB.
|
||||||
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
||||||
at a fixed commit hash.
|
at a fixed commit hash.
|
||||||
- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532).
|
- The `sww` binary, and the user `sww` (uid and gid 65532).
|
||||||
- The service directory `/etc/service/smallwebwaf`, with the `run` script
|
- The service directory `/etc/service/sww`, with the `run` script
|
||||||
`share/smallwebwaf.run`.
|
`share/sww.run`.
|
||||||
- `/var/lib/smallwebwaf` for the state files, and `/etc/smallwebwaf/rules.d`
|
- `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
|
||||||
with the default rule file `share/rules.d/00-default.rules`.
|
the default rule file `share/rules.d/00-default.rules`.
|
||||||
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`.
|
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
|
||||||
|
|
||||||
## Pins
|
## Pins
|
||||||
|
|
||||||
- Ubuntu's packages: the snapshot service, `--snapshot 20261001T000000Z` on both
|
- Ubuntu's packages are not pinned: they come from the live archive, whose
|
||||||
`apt-get update` and `apt-get install`. The SHA-256 hash of each of the
|
packages change with every update.
|
||||||
snapshot's `InRelease` files is named in the `Dockerfile` and checked after
|
|
||||||
`apt-get update`. The hashes are the amd64 archive's.
|
|
||||||
- nixpkgs: the release file `nixexprs.tar.xz` of
|
- nixpkgs: the release file `nixexprs.tar.xz` of
|
||||||
`nixos-26.05.11045.774debe7a0d1` on `releases.nixos.org`, named with its
|
`nixos-26.05.11045.774debe7a0d1` on `releases.nixos.org`, named with its
|
||||||
SHA-256 hash.
|
SHA-256 hash.
|
||||||
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
||||||
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
||||||
build writes.
|
build writes.
|
||||||
- The Go image that builds `smallwebwaf`: by digest.
|
- The Go image that builds `sww`: by digest.
|
||||||
+52
-49
@@ -1,81 +1,84 @@
|
|||||||
# The metrics
|
# The metrics
|
||||||
|
|
||||||
The metrics `GET /_smallwebwaf/metrics` answers with, as "Metrics endpoint" in
|
What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
|
||||||
[`SPEC.md`](../SPEC.md) describes it. Prometheus keeps the `instance` label as
|
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
||||||
`exported_instance` unless the scrape sets `honor_labels: true`.
|
keeps the `instance` label as `exported_instance` unless the scrape sets
|
||||||
|
`honor_labels: true`. `rproxy` has no metrics.
|
||||||
|
|
||||||
## Traffic
|
## Traffic
|
||||||
|
|
||||||
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
- `sww_requests_total`, `sww_request_bytes_total` and
|
||||||
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by
|
`sww_response_bytes_total`, by `status_class` such as `2xx` and by `action`;
|
||||||
`action`;
|
- `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
|
||||||
- `smallwebwaf_request_duration_seconds` and
|
histograms;
|
||||||
`smallwebwaf_upstream_duration_seconds`, as histograms;
|
- `sww_requests_in_flight`: the questions and requests to its own endpoints that
|
||||||
- `smallwebwaf_requests_in_flight`.
|
`discriminator` is handling.
|
||||||
|
|
||||||
## Limits and bans
|
## Limits and bans
|
||||||
|
|
||||||
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`,
|
- `sww_rate_limit_hits_total`, by `window` and by `kind` (`requests`, `bytes` or
|
||||||
`bytes` or `refusals`);
|
`refusals`);
|
||||||
- `smallwebwaf_size_and_time_limit_hits_total`, by `limit`, the setting passed;
|
- `sww_offences_total`, by `kind`;
|
||||||
- `smallwebwaf_offences_total`, by `kind`;
|
- `sww_bans_made_total`, by `cause`;
|
||||||
- `smallwebwaf_bans_made_total`, by `cause`;
|
- `sww_active_bans` and `sww_permanent_bans`.
|
||||||
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
|
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and
|
- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
|
||||||
`smallwebwaf_rules_loaded`;
|
- `sww_waf_matches_total`, by `mode` and `rule_id`, for the rules that fired.
|
||||||
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that
|
|
||||||
fired.
|
|
||||||
|
|
||||||
## Countries and AS numbers
|
## Countries and AS numbers
|
||||||
|
|
||||||
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||||
|
|
||||||
- `smallwebwaf_country_requests_total`,
|
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
||||||
`smallwebwaf_country_request_bytes_total`,
|
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
||||||
`smallwebwaf_country_response_bytes_total` and
|
`country`;
|
||||||
`smallwebwaf_country_list_refusals_total`, by `country`;
|
- `sww_asn_requests_total`, `sww_asn_request_bytes_total` and
|
||||||
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and
|
`sww_asn_response_bytes_total`, by `asn`.
|
||||||
`smallwebwaf_asn_response_bytes_total`, by `asn`.
|
|
||||||
|
|
||||||
## Lookups
|
## Lookups
|
||||||
|
|
||||||
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`;
|
- `sww_geojs_requests_total` and `sww_geojs_failures_total`;
|
||||||
- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and
|
- `sww_geojs_unanswered_total`: the requests that needed an answer and went on
|
||||||
went on without it;
|
without it;
|
||||||
- with the file source,
|
- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
|
||||||
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and
|
`sww_lookup_database_read_failures_total`.
|
||||||
`smallwebwaf_lookup_database_read_failures_total`.
|
|
||||||
|
|
||||||
## Reputation sources
|
## Reputation sources
|
||||||
|
|
||||||
By `source`: each list's URL, each zone, and `abuseipdb`:
|
By `source`: each list's URL, each zone, and `abuseipdb`:
|
||||||
|
|
||||||
- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total`
|
- `sww_reputation_hits_total`, `sww_reputation_queries_total` and
|
||||||
and `smallwebwaf_reputation_failures_total`;
|
`sww_reputation_failures_total`;
|
||||||
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list;
|
- `sww_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||||
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB.
|
- `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||||
|
|
||||||
## State files
|
## State files
|
||||||
|
|
||||||
By `file`:
|
By `file`:
|
||||||
|
|
||||||
- `smallwebwaf_state_file_writes_total`,
|
- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
|
||||||
`smallwebwaf_state_file_write_failures_total`,
|
`sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
|
||||||
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
- `sww_state_file_edits_taken_in_total` and
|
||||||
`smallwebwaf_state_file_size_bytes`;
|
`sww_state_file_edits_set_aside_total`;
|
||||||
- `smallwebwaf_state_file_edits_taken_in_total` and
|
- `sww_tracked_clients`: the clients in the table.
|
||||||
`smallwebwaf_state_file_edits_set_aside_total`;
|
|
||||||
- `smallwebwaf_tracked_clients`: the clients in the table.
|
|
||||||
|
|
||||||
## Remote log, alerts and the process
|
## Remote log, alerts and the process
|
||||||
|
|
||||||
- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`,
|
- while a syslog server is set, `sww_remote_log_lines_sent_total`,
|
||||||
`smallwebwaf_remote_log_lines_dropped_total` and
|
`sww_remote_log_lines_dropped_total` and `sww_remote_log_buffer_depth`;
|
||||||
`smallwebwaf_remote_log_buffer_depth`;
|
- by `destination`, `sww_alerts_sent_total`, `sww_alerts_failed_total`,
|
||||||
- by `destination`, `smallwebwaf_alerts_sent_total`,
|
`sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
|
||||||
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
|
||||||
`smallwebwaf_alerts_dropped_total`;
|
|
||||||
- Go's own `go_` metrics and the process's `process_` metrics.
|
- Go's own `go_` metrics and the process's `process_` metrics.
|
||||||
|
|
||||||
|
## When a request is counted
|
||||||
|
|
||||||
|
`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.
|
||||||
+164
-44
@@ -1,18 +1,37 @@
|
|||||||
# The request log's fields
|
# The request log's fields
|
||||||
|
|
||||||
The fields of the JSON object `smallwebwaf` writes for each request. "Request
|
The fields of the JSON objects `rproxy` and `discriminator` write about each
|
||||||
log" in [`SPEC.md`](../SPEC.md) says when a line is written and how the log is
|
request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
|
||||||
sent to a syslog server. A field that does not apply to a request is left out.
|
is written and how the log is sent to a syslog server. A field that does not
|
||||||
|
apply to a request is left out.
|
||||||
|
|
||||||
## Example
|
## `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","time":"2026-10-03T12:00:00.123Z","instance":"fsn1app1/gitea","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":"*/*"},"response_content_type":"text/html; charset=utf-8","upstream_status":200,"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"duration_total":3.217,"duration_checks":0.041,"duration_upstream_connect":0.052,"duration_upstream_first_byte":2.874,"duration_upstream_total":3.104}
|
{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Every line
|
### Every line
|
||||||
|
|
||||||
Every line has:
|
Every one of these lines has:
|
||||||
|
|
||||||
- `type`: `request` (the process's own messages have `process`);
|
- `type`: `request` (the process's own messages have `process`);
|
||||||
- the standard web log fields;
|
- the standard web log fields;
|
||||||
@@ -20,21 +39,39 @@ Every line has:
|
|||||||
- `asn`, `as_name` and `country`;
|
- `asn`, `as_name` and `country`;
|
||||||
- `action` and `duration_total`.
|
- `action` and `duration_total`.
|
||||||
|
|
||||||
## Standard web log fields
|
### Standard web log fields
|
||||||
|
|
||||||
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC;
|
- `time`: when `discriminator` had read the question's body, RFC 3339 with
|
||||||
- `instance`: `SWWAF_INSTANCE_NAME`;
|
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
||||||
- `client_ip`, `method`, `scheme`, `host`, `path`, `query`, `protocol`;
|
line.
|
||||||
- `status`: what the client was sent, `0` if nothing;
|
- `instance`: `SWW_INSTANCE_NAME`.
|
||||||
- `request_bytes` and `response_bytes`: body bytes each way;
|
- `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`.
|
- `referer` and `user_agent`.
|
||||||
|
|
||||||
## Request detail
|
### Request detail
|
||||||
|
|
||||||
- `request_id`: the `X-Request-ID` a trusted proxy sent, or a new random one of
|
- `request_id`: the id `rproxy` made for the request when it arrived.
|
||||||
26 letters and digits.
|
- `peer_ip`: the address of `rproxy`'s TCP peer, normally traefik.
|
||||||
- `peer_ip`: the TCP peer, normally traefik.
|
- `forwarded_for`: the `X-Forwarded-For` header the visitor's request came with.
|
||||||
- `forwarded_for`: the `X-Forwarded-For` header as received.
|
|
||||||
- `client_group`: the client as rate limiting counts it, an IPv4 address as a
|
- `client_group`: the client as rate limiting counts it, an IPv4 address as a
|
||||||
/32 or an IPv6 group.
|
/32 or an IPv6 group.
|
||||||
- `asn`, `as_name` and `country`: the client's, as looked up. Each is empty
|
- `asn`, `as_name` and `country`: the client's, as looked up. Each is empty
|
||||||
@@ -42,37 +79,65 @@ Every line has:
|
|||||||
- lookups are off;
|
- lookups are off;
|
||||||
- the client is not looked up;
|
- the client is not looked up;
|
||||||
- the source cannot place the client;
|
- the source cannot place the client;
|
||||||
- GeoJS had not answered by the time the request went on.
|
- GeoJS had not answered by the time `discriminator` answered.
|
||||||
- `content_type` and `content_length`: the request's own.
|
- `content_type`: the visitor's `Content-Type`.
|
||||||
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
|
- `content_length`: the body length the visitor announced.
|
||||||
in lower case.
|
- `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.
|
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
||||||
Their values are never logged.
|
Their values are never logged.
|
||||||
- `websocket`: true when the app switched the connection to another protocol.
|
|
||||||
|
|
||||||
## Response detail
|
### Response detail
|
||||||
|
|
||||||
- `upstream_status`: what the app answered. It differs from `status` when
|
- `response_content_type`: the type of `discriminator`'s own answer.
|
||||||
`smallwebwaf` answered itself.
|
- `aborted`: true when `rproxy` went away before the answer.
|
||||||
- `response_content_type`, `cache_control` and `location`: the answer's headers.
|
|
||||||
- `aborted`: true when the client went away early.
|
|
||||||
|
|
||||||
## Decision
|
### Decision
|
||||||
|
|
||||||
- `action`: what was done:
|
- `action`: what was done:
|
||||||
- `forward`: passed to the app;
|
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||||
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
|
- `denied`: refused for `SWW_DENY_NETS`, for a reputation source with the
|
||||||
the action `deny`;
|
action `deny`, or for a tag in `SWW_TAG_DENY`;
|
||||||
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
||||||
CrowdSec decision, each of which bans the client;
|
CrowdSec decision, each of which bans the client;
|
||||||
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||||
refused by that check;
|
refused by that check;
|
||||||
- `too_large` and `timed_out`: a size or time limit was passed;
|
- `too_large`: over a header limit;
|
||||||
- `upstream_error`: the app could not be reached, or its answer broke off;
|
- `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
|
||||||
- `admin`: answered at one of `smallwebwaf`'s own endpoints.
|
a path under `/_sww/`.
|
||||||
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
||||||
request it would have refused. `action` then names what was done, normally
|
request it would have refused. `action` then names what was done, normally
|
||||||
`forward`.
|
`forward`.
|
||||||
|
- `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.
|
||||||
|
|
||||||
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
|
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
|
||||||
rate limits and the setting that lowered it, when one did. `bytes_percent` and
|
rate limits and the setting that lowered it, when one did. `bytes_percent` and
|
||||||
`bytes_percent_setting` do the same for the byte limits.
|
`bytes_percent_setting` do the same for the byte limits.
|
||||||
@@ -83,7 +148,9 @@ Every line has:
|
|||||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
|
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
|
||||||
|
|
||||||
A count can have a fraction (see "Counting method" in
|
A count can have a fraction (see "Counting method" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
|
||||||
|
its report comes. So on the `decision` line, the bytes are without this
|
||||||
|
request's.
|
||||||
|
|
||||||
- `limit_hit`: the limit broken:
|
- `limit_hit`: the limit broken:
|
||||||
- `minute`, `hour` or `day` for a rate limit;
|
- `minute`, `hour` or `day` for a rate limit;
|
||||||
@@ -91,7 +158,7 @@ Every line has:
|
|||||||
- `error_burst`.
|
- `error_burst`.
|
||||||
|
|
||||||
A request whose bytes broke a limit was passed on whole, so its `action`
|
A request whose bytes broke a limit was passed on whole, so its `action`
|
||||||
stays `forward`.
|
stays `forward`. Only its `report` line gives that limit.
|
||||||
|
|
||||||
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
||||||
refused.
|
refused.
|
||||||
@@ -102,18 +169,71 @@ Every line has:
|
|||||||
- the CrowdSec decision list's URL;
|
- the CrowdSec decision list's URL;
|
||||||
- DNS blocklist zones;
|
- DNS blocklist zones;
|
||||||
- `abuseipdb`.
|
- `abuseipdb`.
|
||||||
|
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
||||||
|
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
||||||
- `offence`: `limit`, for a request that broke a limit.
|
- `offence`: `limit`, for a request that broke a limit.
|
||||||
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||||
refused under one: a time, or `permanent`.
|
refused under one: a time, or `permanent`.
|
||||||
|
|
||||||
## Timings
|
### Timings
|
||||||
|
|
||||||
All in milliseconds:
|
All in milliseconds:
|
||||||
|
|
||||||
- `duration_total`: from the request's headers being read to its log line;
|
- `duration_total`:
|
||||||
- `duration_checks`: to the end of the checks, before forwarding;
|
- 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`: to the end of the checks;
|
||||||
- `duration_waf`: the part of that the Core Rule Set took;
|
- `duration_waf`: the part of that the Core Rule Set took;
|
||||||
- `duration_upstream_connect`: from the hand-over to the app until there was a
|
- `duration_upstream_total`: on the `report` line of an allowed request, from
|
||||||
connection;
|
the hand-over to the app to the end of the answer.
|
||||||
- `duration_upstream_first_byte`: until the first byte of the answer;
|
|
||||||
- `duration_upstream_total`: until 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`: `admin`.
|
||||||
|
- `duration_total`: from the request's headers being read to the line.
|
||||||
|
|
||||||
|
## `rproxy`'s line
|
||||||
|
|
||||||
|
`sww rproxy` writes one short line for each request. `request_id` joins it to
|
||||||
|
`discriminator`'s two. It has these fields and no others:
|
||||||
|
|
||||||
|
- `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`: 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`: 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":1.204,"duration_response":3.104,"disposition":"allow"}
|
||||||
|
```
|
||||||
+11
-5
@@ -32,7 +32,7 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
|||||||
## The default file
|
## The default file
|
||||||
|
|
||||||
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
||||||
`/etc/smallwebwaf/rules.d/00-default.rules`:
|
`/etc/sww/rules.d/00-default.rules`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
||||||
@@ -54,14 +54,20 @@ empty-agent user_agent log ^$
|
|||||||
|
|
||||||
## A file of your own
|
## A file of your own
|
||||||
|
|
||||||
An app's `Dockerfile` adds rules of its own in a file named to sort after the
|
`discriminator` reads the rule files in its own container. Rules of your own go
|
||||||
default, such as `50-gitea.rules`:
|
in a file named to sort after the default, such as `50-site.rules`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# 50-gitea.rules: WordPress probes, which gitea never serves
|
# 50-site.rules: WordPress probes, which this site never serves
|
||||||
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
|
||||||
|
from, adds it:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
COPY 50-gitea.rules /etc/smallwebwaf/rules.d/50-gitea.rules
|
COPY 50-site.rules /etc/sww/rules.d/50-site.rules
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or mount it there. A `path` rule applies on every site behind that
|
||||||
|
`discriminator`.
|
||||||
+301
-161
@@ -4,6 +4,10 @@ Every setting, its default and the values it takes, in the groups of "Settings"
|
|||||||
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
||||||
that take `off`, the `_FILE` form, and what each group does and why.
|
that take `off`, the `_FILE` form, and what each group does and why.
|
||||||
|
|
||||||
|
The settings that start with `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
|
## Forms of values
|
||||||
|
|
||||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`. Zero
|
||||||
@@ -24,246 +28,303 @@ that take `off`, the `_FILE` form, and what each group does and why.
|
|||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
- `SWWAF_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
|
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
|
||||||
`https` URL with a host and an optional port, and nothing more.
|
|
||||||
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `smallwebwaf` listens: `:` and a
|
|
||||||
port, such as `:9000`. Leave the host out (see "Ports" in
|
port, such as `:9000`. Leave the host out (see "Ports" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance`
|
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
|
||||||
name in every log line, alert and metric, such as `fsn1app1/gitea`. Valid
|
in each line `discriminator` logs, and in each alert and metric, such as
|
||||||
UTF-8. No control character while `SWWAF_ALERT_NTFY_URL` is set. 1 to 48
|
`fsn1/discriminator`. Valid UTF-8. No control character while
|
||||||
printable ASCII characters without a space while `SWWAF_LOG_REMOTE_URL` is set
|
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
||||||
and `SWWAF_LOG_REMOTE_APP_NAME` is not.
|
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
|
||||||
- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`.
|
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
|
||||||
- `SWWAF_TRUSTED_PROXIES` (default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`):
|
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
|
||||||
the netblocks whose `X-Forwarded-For` is believed. Set but empty, it trusts
|
group, from 32 to 128.
|
||||||
nothing.
|
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||||
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
|
|
||||||
client's group, from 32 to 128.
|
|
||||||
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
|
||||||
number above zero.
|
number above zero.
|
||||||
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `smallwebwaf`
|
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
|
||||||
keeps, a whole number above zero.
|
keeps, a whole number above zero.
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||||
`/_smallwebwaf/clients/<ip>`. At least 32 characters.
|
`/_sww/clients/<ip>`. At least 32 characters.
|
||||||
- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_smallwebwaf/metrics`.
|
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
|
||||||
At least 32 characters.
|
32 characters.
|
||||||
|
|
||||||
## State files
|
## State files
|
||||||
|
|
||||||
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
|
||||||
files, an absolute path that exists and can be written.
|
absolute path that exists and can be written.
|
||||||
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||||
written after a ban is made, lifted or made permanent. A duration.
|
written after a ban is made, lifted or made permanent. A duration.
|
||||||
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||||
written. A duration.
|
written. A duration.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `smallwebwaf`'s own
|
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
|
||||||
messages that are written: `debug`, `info`, `warn` or `error`.
|
messages that are written: `debug`, `info`, `warn` or `error`.
|
||||||
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
- `SWW_LOG_REQUEST_HEADERS` (default
|
||||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||||
request headers recorded in the log line, in either case. `Host` and
|
request headers recorded in the log line, in either case. `Host` and
|
||||||
`Transfer-Encoding` are refused; the host is the field `host`.
|
`Transfer-Encoding` are refused; the host is the field `host`.
|
||||||
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||||
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
||||||
each with a host and a port and nothing more.
|
each with a host and a port and nothing more.
|
||||||
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
|
||||||
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
a `syslog+tls` server's certificate must chain to, instead of the host's own.
|
||||||
own. A file that cannot be read, or holds no certificate, is refused.
|
A file that cannot be read, or holds no certificate, is refused.
|
||||||
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
|
||||||
wait to be sent, a whole number above zero.
|
to be sent, a whole number above zero.
|
||||||
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||||
- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name in
|
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
|
||||||
each syslog record, 1 to 48 printable ASCII characters without a space.
|
syslog record, 1 to 48 printable ASCII characters without a space.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
|
||||||
countries get series of their own. A whole number above zero.
|
get series of their own. A whole number above zero.
|
||||||
|
|
||||||
## Static lists
|
## Static lists
|
||||||
|
|
||||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
||||||
monitoring. Their clients are refused only for a size or time limit or a
|
(see step 4 in [`SPEC.md`](../SPEC.md)).
|
||||||
missing or wrong token.
|
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
|
||||||
exempt from rate limiting and the byte limits.
|
exempt from rate limiting and the byte limits.
|
||||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are always refused.
|
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
||||||
|
4 in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Request rate limits, per client
|
## Request rate limits, per client
|
||||||
|
|
||||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `1000`), `SWWAF_RATE_LIMIT_PER_HOUR`
|
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||||
(default `10000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `50000`): the most
|
client may make in a minute. A count or `off`. The default leaves room for a
|
||||||
requests a client may make in each window. A count or `off`.
|
person opening two pages at once with an empty cache, up to 60 files each.
|
||||||
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||||
rate limiting and the byte limits, such as `/assets/`.
|
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
|
## Byte limits, per client
|
||||||
|
|
||||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `10G`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
||||||
(default `20G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `50G`): the most
|
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
||||||
bytes counted for a client in each window. A size or `off`.
|
counted for a client in each window. A size or `off`.
|
||||||
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
- `SWW_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||||
`response`, `request` or `both`.
|
`response`, `request` or `both`.
|
||||||
|
|
||||||
## Size and time limits
|
|
||||||
|
|
||||||
- `SWWAF_CLIENT_REQUEST_TIMEOUT` (default `60s`): how long a client may take to
|
|
||||||
send its request line and headers, and then, from the end of the headers, its
|
|
||||||
body. A duration or `off`.
|
|
||||||
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest request
|
|
||||||
line and headers. A size over `4K`; never `off`.
|
|
||||||
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open connection
|
|
||||||
may wait for its next request before it is closed. A duration or `off`.
|
|
||||||
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` (default `30m`): how long the answer may take
|
|
||||||
to reach the client, from the end of the request to the last byte. A duration
|
|
||||||
or `off`.
|
|
||||||
- `SWWAF_UPSTREAM_REQUEST_TIMEOUT` (default `60s`): how long connecting to the
|
|
||||||
app and sending it the whole request may take. A duration or `off`.
|
|
||||||
- `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` (default `30m`): how long the app may take
|
|
||||||
to send its whole answer. A duration or `off`.
|
|
||||||
- `SWWAF_REQUEST_MAX_BYTES` (default `100M`): the largest request body. A size
|
|
||||||
or `off`.
|
|
||||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body. A size
|
|
||||||
or `off`.
|
|
||||||
|
|
||||||
## Lookup of AS number and country
|
## Lookup of AS number and country
|
||||||
|
|
||||||
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`.
|
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||||
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
the start while a setting that needs a lookup is set:
|
||||||
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
||||||
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its
|
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||||
|
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming an `asn=` or `country=`
|
||||||
|
tag;
|
||||||
|
- 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
|
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
||||||
duration; never `off`.
|
duration; never `off`.
|
||||||
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` passes the app the
|
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||||
client's AS number in `X-Client-ASN` and its country in `X-Client-Country`.
|
the client's AS number in `X-Client-ASN` and its country in
|
||||||
|
`X-Client-Country`.
|
||||||
|
|
||||||
## Country lists
|
## Country list
|
||||||
|
|
||||||
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||||
such as `cn,ru,kp`.
|
countries whose clients get through, such as `us,de`. A country whose
|
||||||
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
`country=` tag is in `SWW_TAG_DENY` stops the start.
|
||||||
countries whose clients get through, such as `us,de`. A country on both lists
|
|
||||||
stops the start.
|
## Request tags
|
||||||
|
|
||||||
|
Every request gets tags from what `discriminator` knows of its client. See
|
||||||
|
"Request tags" in [`SPEC.md`](../SPEC.md).
|
||||||
|
|
||||||
|
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
|
||||||
|
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
|
||||||
|
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
|
||||||
|
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
|
||||||
|
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
|
||||||
|
or the CrowdSec decision list.
|
||||||
|
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
|
||||||
|
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
|
||||||
|
`country=` tag without a country, is refused.
|
||||||
|
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
|
||||||
|
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
|
||||||
|
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
|
||||||
|
client's tags applies. A tag listed twice is refused. So is
|
||||||
|
`exploitsignature`: a request gets that tag after the rate limits have counted
|
||||||
|
it.
|
||||||
|
|
||||||
|
`SWW_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 `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
|
||||||
|
masked as the `reputation` field shows it;
|
||||||
|
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
|
||||||
|
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is
|
||||||
|
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is
|
||||||
|
`off`.
|
||||||
|
|
||||||
|
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
||||||
|
the request first.
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
||||||
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
percentage of the byte limits alone, for the AS numbers or countries listed,
|
||||||
listed twice is refused.
|
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
||||||
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
|
||||||
`cn:25,ru:50`.
|
country listed twice is refused.
|
||||||
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty):
|
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||||
the same, for the byte limits alone.
|
|
||||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
|
||||||
lookup cannot place.
|
lookup cannot place.
|
||||||
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||||
text file with one AS number and percentage on each line, such as
|
text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused.
|
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
|
||||||
|
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
|
||||||
rule files.
|
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||||
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||||
- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
|
||||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
||||||
- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||||
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||||
from 1 to 4.
|
from 1 to 4.
|
||||||
- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||||
match. A whole number above zero, or `off`.
|
match. A whole number above zero, or `off`.
|
||||||
- `SWWAF_WAF_DISABLED_RULES` (default
|
- `SWW_WAF_DISABLED_RULES` (default
|
||||||
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
||||||
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
||||||
refused: those are the rules that set the Core Rule Set up, and
|
refused: those are the rules that set the Core Rule Set up, and
|
||||||
`smallwebwaf`'s own.
|
`smallwebwaf`'s own.
|
||||||
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||||
not inspect.
|
not inspect.
|
||||||
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
|
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
|
||||||
the Core Rule Set reads, a size of at most `1G`.
|
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
|
||||||
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
|
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
|
||||||
in a minute for a rule match, a trap path, a Core Rule Set match or a failed
|
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||||
token check. A count or `off`.
|
- `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_APPS` (default unset): the apps each hostname serves. A request gets the
|
||||||
|
fixes of its hostname's apps only (see "Attack detection" in
|
||||||
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
- Entries are separated by semicolons. Each is a hostname, a colon, then one
|
||||||
|
or more apps separated by commas, for example
|
||||||
|
`git.example.com:gitea;code.example.com:gitea`.
|
||||||
|
- `gitea` is the only app supported.
|
||||||
|
- The request's `Host` is matched by exact name, without its port and
|
||||||
|
without regard to case. There are no wildcards.
|
||||||
|
- A hostname not listed gets no app's fixes, so every rule checks it in
|
||||||
|
full. Set empty, it lists no hostname.
|
||||||
|
- Unset, every app's fixes apply to every hostname.
|
||||||
|
- Any of these mistakes stops the start:
|
||||||
|
- an unknown app;
|
||||||
|
- a hostname that is not a DNS name, such as `*.example.com`;
|
||||||
|
- a hostname listed twice;
|
||||||
|
- an app listed twice for one hostname;
|
||||||
|
- an entry without a colon.
|
||||||
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
|
||||||
|
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||||
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||||
duration.
|
duration.
|
||||||
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||||
within which breaking a limit again lengthens the next ban. A duration.
|
within which breaking a limit again lengthens the next ban. A duration.
|
||||||
- `SWWAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
|
||||||
one that would be longer is permanent. A duration.
|
that would be longer is permanent. A duration.
|
||||||
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||||
attack. A duration.
|
attack. A duration.
|
||||||
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||||
covers: the prefix length of its netblock, from 0 to 32.
|
covers: the prefix length of its netblock, from 0 to 32.
|
||||||
|
|
||||||
## Reputation
|
## Reputation
|
||||||
|
|
||||||
- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||||
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
||||||
without a user or a fragment. A URL listed twice is refused.
|
without a user or a fragment. A URL listed twice is refused.
|
||||||
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
|
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
|
||||||
last try before it is fetched again. A duration of `1h` or more.
|
try before it is fetched again. A duration of `1h` or more.
|
||||||
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||||
- `SWWAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
||||||
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
||||||
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
||||||
is refused, whatever its case or key.
|
is refused, whatever its case or key.
|
||||||
- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
||||||
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
||||||
port, 53 when none is given. Unset, the host's resolver is used.
|
port, 53 when none is given. Unset, the host's resolver is used.
|
||||||
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||||
control character in it is refused.
|
control character in it is refused.
|
||||||
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||||
from 0 to 100, that is a hit.
|
from 0 to 100, that is a hit.
|
||||||
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day,
|
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
|
||||||
a whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
||||||
- `SWWAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
||||||
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
||||||
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
||||||
without the key, it is refused. So is a decision list URL that is also in
|
without the key, it is refused. So is a decision list URL that is also in
|
||||||
`SWWAF_BLOCKLIST_URLS`, or that is `SWWAF_ASN_LIMIT_PERCENT_URL`.
|
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
|
||||||
- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||||
URL, it is refused.
|
URL, it is refused.
|
||||||
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||||
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||||
AbuseIPDB's score is used. A duration.
|
AbuseIPDB's score is used. A duration.
|
||||||
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
|
||||||
check with AbuseIPDB may take. A duration.
|
with AbuseIPDB may take. A duration.
|
||||||
|
|
||||||
## Alerting
|
## Alerting
|
||||||
|
|
||||||
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
|
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
|
||||||
alert as a JSON object. An `http` or `https` URL without a user or a fragment.
|
as a JSON object. An `http` or `https` URL without a user or a fragment.
|
||||||
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
|
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
|
||||||
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
|
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
|
||||||
value. A value cannot hold a comma.
|
value cannot hold a comma.
|
||||||
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
|
||||||
that receives each alert as a message, checked as the webhook URL is.
|
receives each alert as a message, checked as the webhook URL is.
|
||||||
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
|
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
|
||||||
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
|
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
|
||||||
as the webhook URL is.
|
webhook URL is.
|
||||||
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||||
character in it is refused.
|
character in it is refused.
|
||||||
- `SWWAF_ALERT_EVENTS` (default
|
- `SWW_ALERT_EVENTS` (default
|
||||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||||
but `summary`. Any other name is refused.
|
but `summary`. Any other name is refused.
|
||||||
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||||
repeats are held back. A duration or `off`.
|
repeats are held back. A duration or `off`.
|
||||||
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||||
whole number above zero, or `off`.
|
whole number above zero, or `off`.
|
||||||
|
|
||||||
## Anomaly thresholds
|
## Anomaly thresholds
|
||||||
@@ -272,25 +333,25 @@ Each scope has four thresholds: the most requests and the most bytes counted in
|
|||||||
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
||||||
default. The four thresholds per client are:
|
default. The four thresholds per client are:
|
||||||
|
|
||||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||||
|
|
||||||
The other scopes' names have another prefix before the same four endings, such
|
The other scopes' names have another prefix before the same four endings, such
|
||||||
as `REQUESTS_PER_MINUTE`:
|
as `REQUESTS_PER_MINUTE`:
|
||||||
|
|
||||||
- `SWWAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||||
netblock's length:
|
netblock's length:
|
||||||
- `SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||||
client;
|
client;
|
||||||
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||||
client.
|
client.
|
||||||
- `SWWAF_ANOMALY_ASN_...`: per AS number. Needs a lookup source.
|
- `SWW_ANOMALY_ASN_...`: per AS number.
|
||||||
- `SWWAF_ANOMALY_TOTAL_...`: the whole service.
|
- `SWW_ANOMALY_TOTAL_...`: the whole service.
|
||||||
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole.
|
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
|
||||||
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
|
||||||
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||||
name, or with a bad netblock, is refused. So is a name used twice.
|
name, or with a bad netblock, is refused. So is a name used twice.
|
||||||
|
|
||||||
## Settings given as files
|
## Settings given as files
|
||||||
@@ -309,8 +370,9 @@ Then mount its directory into the container read-only, and name the file:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run \
|
docker run \
|
||||||
-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \
|
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
||||||
-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \
|
-e SWW_COMMAND=discriminator \
|
||||||
|
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -322,4 +384,82 @@ A few limits are fixed rather than settings:
|
|||||||
- at most 100,000 DNS blocklist verdicts and 100,000 AbuseIPDB scores 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 1,000 DNS blocklist queries are under way at once;
|
||||||
- at most 20,000 anomaly counters are kept;
|
- at most 20,000 anomaly counters are kept;
|
||||||
- each alert destination's queue holds at most 1,000 alerts.
|
- each alert destination's queue holds at most 1,000 alerts;
|
||||||
|
- `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.
|
||||||
+9
-9
@@ -1,13 +1,13 @@
|
|||||||
# State files
|
# State files
|
||||||
|
|
||||||
The five JSON files in `SWWAF_STATE_DIR`, and what each holds. "Persistent
|
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
|
||||||
state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited.
|
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
|
||||||
Each has a top-level `version` of 1.
|
a top-level `version` of 1.
|
||||||
|
|
||||||
## `bans.json`
|
## `bans.json`
|
||||||
|
|
||||||
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of
|
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
||||||
those `smallwebwaf` made. Per entry:
|
`discriminator` made. Per entry:
|
||||||
|
|
||||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||||
@@ -63,8 +63,8 @@ since it was first seen:
|
|||||||
- total requests, and how many were forwarded and how many refused;
|
- total requests, and how many were forwarded and how many refused;
|
||||||
- bytes in each direction;
|
- bytes in each direction;
|
||||||
- responses by status class;
|
- responses by status class;
|
||||||
- offences by kind: `limit`, `attack`, `rule_blocked`, `waf_blocked` and
|
- offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file
|
||||||
`token_refused`.
|
may also hold `token_refused`, which is kept, and which nothing adds to.
|
||||||
|
|
||||||
## `lookups.json`
|
## `lookups.json`
|
||||||
|
|
||||||
@@ -93,10 +93,10 @@ GeoJS answers, one per line:
|
|||||||
## Sizes
|
## Sizes
|
||||||
|
|
||||||
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
||||||
`SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||||
disk writes a day.
|
disk writes a day.
|
||||||
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
||||||
the default `SWWAF_MAX_BANS`. Written every 15 minutes, a full file comes to
|
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||||
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
||||||
more.
|
more.
|
||||||
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
||||||
|
|||||||
+49
-43
@@ -1,12 +1,12 @@
|
|||||||
// Package alerts sends alerts on bans, on traffic over an anomaly
|
// Package alerts sends alerts on bans, on traffic over an anomaly
|
||||||
// threshold, on a source that fails and on a file with an error to each
|
// threshold, on a source that fails and on a file with an error to each
|
||||||
// destination set: to the webhook
|
// destination set: to the webhook
|
||||||
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as the "Alert
|
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||||
// webhook schema" section of SPEC.md describes, to the Slack incoming
|
// object" in docs/alerts.md describes, to the Slack incoming
|
||||||
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||||
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within
|
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
|
||||||
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past
|
// SWW_ALERT_COOLDOWN is held back, and so is an alert past
|
||||||
// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||||
// bounded queue of each destination's own, so that a destination that is
|
// bounded queue of each destination's own, so that a destination that is
|
||||||
// slow or unreachable holds up neither the others nor any request. The
|
// slow or unreachable holds up neither the others nor any request. The
|
||||||
// state is written to alerts.json and read from it by the state package.
|
// state is written to alerts.json and read from it by the state package.
|
||||||
@@ -34,41 +34,41 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The events an alert is for, as SWWAF_ALERT_EVENTS names them.
|
// The events an alert is for, as SWW_ALERT_EVENTS names them.
|
||||||
const (
|
const (
|
||||||
// EventBan is a ban smallwebwaf made.
|
// EventBan is a ban sww made.
|
||||||
EventBan = "ban"
|
EventBan = "ban"
|
||||||
// EventPermanentBan is a permanent ban smallwebwaf made, or a ban it
|
// EventPermanentBan is a permanent ban sww made, or a ban it
|
||||||
// made permanent.
|
// made permanent.
|
||||||
EventPermanentBan = "permanent_ban"
|
EventPermanentBan = "permanent_ban"
|
||||||
// EventAnomaly is a count of requests or bytes over an anomaly
|
// EventAnomaly is a count of requests or bytes over an anomaly
|
||||||
// threshold.
|
// threshold.
|
||||||
EventAnomaly = "anomaly"
|
EventAnomaly = "anomaly"
|
||||||
// EventWAFBlock is a request the Core Rule Set scored at or over
|
// EventWAFBlock is a request the Core Rule Set scored at or over
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||||
// detect mode.
|
// detect mode.
|
||||||
EventWAFBlock = "waf_block"
|
EventWAFBlock = "waf_block"
|
||||||
// EventReputationHit is a request whose client a blocklist, the
|
// EventReputationHit is a request whose client a blocklist, the
|
||||||
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
||||||
// is a hit.
|
// is a hit.
|
||||||
EventReputationHit = "reputation_hit"
|
EventReputationHit = "reputation_hit"
|
||||||
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
|
// EventSourceFailure is GeoJS failing or refusing sww, a fetch
|
||||||
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
||||||
// failing or refused, or the day's AbuseIPDB checks used up.
|
// failing or refused, or the day's AbuseIPDB checks used up.
|
||||||
EventSourceFailure = "source_failure"
|
EventSourceFailure = "source_failure"
|
||||||
// EventFileError is a rule file or state file edited while smallwebwaf
|
// EventFileError is a rule file or state file edited while sww
|
||||||
// runs that does not parse, a replacement of the lookup database that
|
// runs that does not parse, a replacement of the lookup database that
|
||||||
// cannot be read, or a state file that cannot be written.
|
// cannot be read, or a state file that cannot be written.
|
||||||
EventFileError = "file_error"
|
EventFileError = "file_error"
|
||||||
// EventSummary is the summary sent as an hour ends: of the alerts held
|
// EventSummary is the summary sent as an hour ends: of the alerts held
|
||||||
// back in it past SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held
|
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||||
// back by the cooldowns dropped as it ends, which no alert let through
|
// back by the cooldowns dropped as it ends, which no alert let through
|
||||||
// has given. It is sent with SWWAF_ALERT_MAX_PER_HOUR off too, for
|
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
|
||||||
// those repeats. SWWAF_ALERT_EVENTS does not name it.
|
// those repeats. SWW_ALERT_EVENTS does not name it.
|
||||||
EventSummary = "summary"
|
EventSummary = "summary"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Events returns every event SWWAF_ALERT_EVENTS can name, which is its
|
// Events returns every event SWW_ALERT_EVENTS can name, which is its
|
||||||
// default.
|
// default.
|
||||||
func Events() []string {
|
func Events() []string {
|
||||||
return []string{
|
return []string{
|
||||||
@@ -80,12 +80,12 @@ func Events() []string {
|
|||||||
// The destinations alerts are sent to, as the metrics and alerts.json
|
// The destinations alerts are sent to, as the metrics and alerts.json
|
||||||
// name them.
|
// name them.
|
||||||
const (
|
const (
|
||||||
// DestinationWebhook is the webhook SWWAF_ALERT_WEBHOOK_URL names.
|
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
|
||||||
DestinationWebhook = "webhook"
|
DestinationWebhook = "webhook"
|
||||||
// DestinationSlack is the Slack incoming webhook
|
// DestinationSlack is the Slack incoming webhook
|
||||||
// SWWAF_ALERT_SLACK_WEBHOOK_URL names.
|
// SWW_ALERT_SLACK_WEBHOOK_URL names.
|
||||||
DestinationSlack = "slack"
|
DestinationSlack = "slack"
|
||||||
// DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names.
|
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
|
||||||
DestinationNtfy = "ntfy"
|
DestinationNtfy = "ntfy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,26 +121,26 @@ var (
|
|||||||
// NtfyURL set, no alert is sent.
|
// NtfyURL set, no alert is sent.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// WebhookURL is where each alert is posted as JSON
|
// WebhookURL is where each alert is posted as JSON
|
||||||
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||||
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS).
|
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
||||||
WebhookURL *url.URL
|
WebhookURL *url.URL
|
||||||
WebhookHeaders http.Header
|
WebhookHeaders http.Header
|
||||||
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
||||||
// message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||||
SlackURL *url.URL
|
SlackURL *url.URL
|
||||||
// NtfyURL is the ntfy topic each alert is published to
|
// NtfyURL is the ntfy topic each alert is published to
|
||||||
// (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||||
// empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN).
|
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
|
||||||
NtfyURL *url.URL
|
NtfyURL *url.URL
|
||||||
NtfyToken string
|
NtfyToken string
|
||||||
// Events are the events alerts are sent for (SWWAF_ALERT_EVENTS).
|
// Events are the events alerts are sent for (SWW_ALERT_EVENTS).
|
||||||
Events []string
|
Events []string
|
||||||
// Cooldown is how long a repeat of an alert is held back
|
// Cooldown is how long a repeat of an alert is held back
|
||||||
// (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||||
// sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit.
|
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||||
Cooldown time.Duration
|
Cooldown time.Duration
|
||||||
MaxPerHour int
|
MaxPerHour int
|
||||||
// Instance is SWWAF_INSTANCE_NAME, which every alert gives.
|
// Instance is SWW_INSTANCE_NAME, which every alert gives.
|
||||||
Instance string
|
Instance string
|
||||||
// Now tells the time of an alert, normally time.Now in UTC.
|
// Now tells the time of an alert, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -149,15 +149,17 @@ type Params struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||||
// with the fields of the "Alert webhook schema" section of SPEC.md. ASN,
|
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
||||||
// ASName and Country are, for a ban, the client's as the ban's notes give
|
// Country are, for a ban, the client's as the ban's notes give them. Host is
|
||||||
// them.
|
// the site of the request that raised the alert, while one discriminator serves
|
||||||
|
// many sites, and "" otherwise.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // SPEC.md's alert webhook schema names its fields in snake_case
|
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
Instance string `json:"instance"`
|
Instance string `json:"instance"`
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
Client netip.Addr `json:"client"`
|
Client netip.Addr `json:"client"`
|
||||||
Netblock netip.Prefix `json:"netblock"`
|
Netblock netip.Prefix `json:"netblock"`
|
||||||
ASN string `json:"asn"`
|
ASN string `json:"asn"`
|
||||||
@@ -299,17 +301,17 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if params.WebhookURL != nil {
|
if params.WebhookURL != nil {
|
||||||
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL",
|
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
|
||||||
params.WebhookURL, q.webhookMessage)
|
params.WebhookURL, q.webhookMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.SlackURL != nil {
|
if params.SlackURL != nil {
|
||||||
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL",
|
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
|
||||||
params.SlackURL, slackMessage)
|
params.SlackURL, slackMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.NtfyURL != nil {
|
if params.NtfyURL != nil {
|
||||||
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL",
|
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
|
||||||
params.NtfyURL, q.ntfyMessage)
|
params.NtfyURL, q.ntfyMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +319,7 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Raise sends alert, which names its event and what is particular to it,
|
// Raise sends alert, which names its event and what is particular to it,
|
||||||
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event
|
// unless no destination is set or SWW_ALERT_EVENTS leaves its event
|
||||||
// out. It gives alert the instance and the time. An alert that repeats
|
// out. It gives alert the instance and the time. An alert that repeats
|
||||||
// the last one let through less than Cooldown before is held back and
|
// the last one let through less than Cooldown before is held back and
|
||||||
// counted. The next one let through gives that count, unless an hour of
|
// counted. The next one let through gives that count, unless an hour of
|
||||||
@@ -361,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WouldSend reports whether Raise would let an alert for event on
|
// WouldSend reports whether Raise would let an alert for event on
|
||||||
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses
|
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
|
||||||
// event, no alert for event on netblock was let through less than
|
// event, no alert for event on netblock was let through less than
|
||||||
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
||||||
// in the hour under way. Unlike Raise, it counts nothing.
|
// in the hour under way. Unlike Raise, it counts nothing.
|
||||||
@@ -604,13 +606,13 @@ func (q *Queue) endHour(now time.Time) {
|
|||||||
|
|
||||||
if heldBack > 0 {
|
if heldBack > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
||||||
"past the %d an hour SWWAF_ALERT_MAX_PER_HOUR allows", heldBack,
|
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||||
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
||||||
}
|
}
|
||||||
|
|
||||||
if repeats > 0 {
|
if repeats > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
||||||
"SWWAF_ALERT_COOLDOWN that no later alert gives", repeats))
|
"SWW_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(reasons) > 0 {
|
if len(reasons) > 0 {
|
||||||
@@ -681,7 +683,7 @@ func (d *destination) run(ctx context.Context) {
|
|||||||
|
|
||||||
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
||||||
"event", alert.Event, "error", err.Error())
|
"event", alert.Event, "error", err.Error())
|
||||||
case ctx.Err() == nil: // not cut off as smallwebwaf stops
|
case ctx.Err() == nil: // not cut off as sww stops
|
||||||
d.failed.Add(1)
|
d.failed.Add(1)
|
||||||
|
|
||||||
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
||||||
@@ -900,12 +902,16 @@ func title(alert *Alert) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// text returns the text of alert in Slack and ntfy: its reason, then a
|
// text returns the text of alert in Slack and ntfy: its reason, then a
|
||||||
// line for each of its client, netblock and country, the file, source,
|
// line for each of its host, client, netblock and country, the file,
|
||||||
// error and mode its detail gives, and its suppressed repeats, that it
|
// source, error and mode its detail gives, and its suppressed repeats, that
|
||||||
// has.
|
// it has.
|
||||||
func text(alert *Alert) string {
|
func text(alert *Alert) string {
|
||||||
lines := []string{alert.Reason}
|
lines := []string{alert.Reason}
|
||||||
|
|
||||||
|
if alert.Host != "" {
|
||||||
|
lines = append(lines, "host: "+alert.Host)
|
||||||
|
}
|
||||||
|
|
||||||
if alert.Client.IsValid() {
|
if alert.Client.IsValid() {
|
||||||
lines = append(lines, "client: "+alert.Client.String())
|
lines = append(lines, "client: "+alert.Client.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ const (
|
|||||||
// webhookURL is where the alerts are posted, slackURL the Slack
|
// webhookURL is where the alerts are posted, slackURL the Slack
|
||||||
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
||||||
// token of the tests that set one.
|
// token of the tests that set one.
|
||||||
webhookURL = "https://alerts.example/smallwebwaf?team=ops"
|
webhookURL = "https://alerts.example/sww?team=ops"
|
||||||
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||||
ntfyURL = "https://ntfy.example/smallwebwaf-alerts"
|
ntfyURL = "https://ntfy.example/sww-alerts"
|
||||||
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
||||||
// instance is the instance name every alert gives.
|
// instance is the instance name every alert gives.
|
||||||
instance = "fsn1app1/gitea"
|
instance = "fsn1app1/blog"
|
||||||
// started is when each test starts, as an alert gives it, and
|
// started is when each test starts, as an alert gives it, and
|
||||||
// anHourOn an hour later.
|
// anHourOn an hour later.
|
||||||
started = "2000-01-01T00:00:00Z"
|
started = "2000-01-01T00:00:00Z"
|
||||||
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
|
|||||||
|
|
||||||
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
||||||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
||||||
t.Error("a ban alert would be sent, though SWWAF_ALERT_EVENTS leaves it out")
|
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
||||||
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
|
|||||||
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
||||||
|
|
||||||
for setting, params := range map[string]alerts.Params{
|
for setting, params := range map[string]alerts.Params{
|
||||||
"SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||||
"SWWAF_ALERT_NTFY_URL": onlyNtfy,
|
"SWW_ALERT_NTFY_URL": onlyNtfy,
|
||||||
} {
|
} {
|
||||||
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
||||||
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
||||||
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
|
|||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 2 an hour SWWAF_ALERT_MAX_PER_HOUR allows",
|
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
|
||||||
"detail": map[string]any{
|
"detail": map[string]any{
|
||||||
"hour": started,
|
"hour": started,
|
||||||
"count": float64(3),
|
"count": float64(3),
|
||||||
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
|
|||||||
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) {
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
|
||||||
t.Errorf("process log %q names no failure", log.String())
|
t.Errorf("process log %q names no failure", log.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
|
|||||||
wantCounts(t, q, 1, 3, 0, 1)
|
wantCounts(t, q, 1, 3, 0, 1)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"gave up an alert SWWAF_ALERT_WEBHOOK_URL refused"`) {
|
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
|
||||||
t.Errorf("process log %q names no alert given up", log.String())
|
t.Errorf("process log %q names no alert given up", log.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
}
|
}
|
||||||
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 1 an hour SWWAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||||
"by SWWAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||||
|
|
||||||
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
||||||
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
||||||
@@ -901,7 +901,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWWAF_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "hooks.slack.example") ||
|
strings.Contains(logged, "hooks.slack.example") ||
|
||||||
strings.Contains(logged, "T0123") {
|
strings.Contains(logged, "T0123") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
@@ -965,8 +965,8 @@ func TestEachDestinationIsSentOnlyTheAlertsWaitingForIt(t *testing.T) {
|
|||||||
synctest.Wait()
|
synctest.Wait()
|
||||||
|
|
||||||
wantBodies(t, standIns[alerts.DestinationSlack],
|
wantBodies(t, standIns[alerts.DestinationSlack],
|
||||||
`{"text":"*fsn1app1/gitea: file_error*\nsecond"}`,
|
`{"text":"*fsn1app1/blog: file_error*\nsecond"}`,
|
||||||
`{"text":"*fsn1app1/gitea: file_error*\nthird"}`)
|
`{"text":"*fsn1app1/blog: file_error*\nthird"}`)
|
||||||
wantBodies(t, standIns[alerts.DestinationNtfy], "fourth")
|
wantBodies(t, standIns[alerts.DestinationNtfy], "fourth")
|
||||||
|
|
||||||
// alerts.json then lists Slack and ntfy alone, with no alert waiting.
|
// alerts.json then lists Slack and ntfy alone, with no alert waiting.
|
||||||
@@ -1254,7 +1254,7 @@ type eventMessage struct {
|
|||||||
title, priorityAndTag, text string
|
title, priorityAndTag, text string
|
||||||
}
|
}
|
||||||
|
|
||||||
// anAlertForEachEvent returns an alert for each event SWWAF_ALERT_EVENTS
|
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
|
||||||
// names, the first in observe mode, each with its message.
|
// names, the first in observe mode, each with its message.
|
||||||
func anAlertForEachEvent() []eventMessage {
|
func anAlertForEachEvent() []eventMessage {
|
||||||
return []eventMessage{
|
return []eventMessage{
|
||||||
@@ -1265,7 +1265,7 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Reason: "requests per hour over the limit of 10000",
|
Reason: "requests per hour over the limit of 10000",
|
||||||
Detail: map[string]any{"mode": "observe"},
|
Detail: map[string]any{"mode": "observe"},
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: ban", "default no_entry",
|
"fsn1app1/blog: ban", "default no_entry",
|
||||||
"requests per hour over the limit of 10000\nclient: 203.0.113.9\n" +
|
"requests per hour over the limit of 10000\nclient: 203.0.113.9\n" +
|
||||||
"netblock: 203.0.113.0/24\ncountry: DE\nmode: observe",
|
"netblock: 203.0.113.0/24\ncountry: DE\nmode: observe",
|
||||||
},
|
},
|
||||||
@@ -1275,7 +1275,7 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR",
|
Netblock: netip.MustParsePrefix("198.51.100.7/32"), Country: "FR",
|
||||||
Reason: "matched the rule env-file",
|
Reason: "matched the rule env-file",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: permanent_ban", "high no_entry",
|
"fsn1app1/blog: permanent_ban", "high no_entry",
|
||||||
"matched the rule env-file\nclient: 198.51.100.7\n" +
|
"matched the rule env-file\nclient: 198.51.100.7\n" +
|
||||||
"netblock: 198.51.100.7/32\ncountry: FR",
|
"netblock: 198.51.100.7/32\ncountry: FR",
|
||||||
},
|
},
|
||||||
@@ -1284,16 +1284,18 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
Event: alerts.EventWAFBlock, Client: netip.MustParseAddr("192.0.2.1"),
|
||||||
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
Netblock: netip.MustParsePrefix("192.0.2.1/32"),
|
||||||
Reason: "refused by the Core Rule Set",
|
Reason: "refused by the Core Rule Set",
|
||||||
|
Host: "app.example",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: waf_block", "default shield",
|
"fsn1app1/blog: waf_block", "default shield",
|
||||||
"refused by the Core Rule Set\nclient: 192.0.2.1\nnetblock: 192.0.2.1/32",
|
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
|
||||||
|
"netblock: 192.0.2.1/32",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
alerts.Alert{
|
alerts.Alert{
|
||||||
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
|
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
|
||||||
Reason: "requests per minute over the threshold of 5000",
|
Reason: "requests per minute over the threshold of 5000",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: anomaly", "high chart_with_upwards_trend",
|
"fsn1app1/blog: anomaly", "high chart_with_upwards_trend",
|
||||||
"requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24",
|
"requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1302,7 +1304,7 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Netblock: netip.MustParsePrefix("192.0.2.2/32"),
|
Netblock: netip.MustParsePrefix("192.0.2.2/32"),
|
||||||
Reason: "listed by a DNS blocklist",
|
Reason: "listed by a DNS blocklist",
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: reputation_hit", "low label",
|
"fsn1app1/blog: reputation_hit", "low label",
|
||||||
"listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32",
|
"listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1310,7 +1312,7 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
|
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
|
||||||
Detail: map[string]any{"source": "geojs"},
|
Detail: map[string]any{"source": "geojs"},
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: source_failure", "high warning",
|
"fsn1app1/blog: source_failure", "high warning",
|
||||||
"asking GeoJS failed\nsource: geojs",
|
"asking GeoJS failed\nsource: geojs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1318,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: "a rule file has an error, and the rules stay as they were",
|
Reason: "a rule file has an error, and the rules stay as they were",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action",
|
"file": "/etc/sww/rules.d/50-app.rules", "error": "line 2: no action",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"fsn1app1/gitea: file_error", "high warning",
|
"fsn1app1/blog: file_error", "high warning",
|
||||||
"a rule file has an error, and the rules stay as they were\n" +
|
"a rule file has an error, and the rules stay as they were\n" +
|
||||||
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action",
|
"file: /etc/sww/rules.d/50-app.rules\nerror: line 2: no action",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-14
@@ -1,8 +1,8 @@
|
|||||||
// Package anomaly counts requests and bytes over a minute and an hour, per
|
// Package anomaly counts requests and bytes over a minute and an hour, per
|
||||||
// client, per surrounding netblock, per AS number, for the whole service
|
// 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
|
// and per named netblock, and raises an anomaly alert for a count over its
|
||||||
// threshold, as "Anomaly thresholds" under "Configuration surface" in
|
// threshold, as "Anomaly thresholds" under "Settings" in SPEC.md
|
||||||
// SPEC.md describes. It refuses and bans nothing. At most 20,000 counters
|
// 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
|
// are kept, in memory, and written to alerts.json and read from it by the
|
||||||
// state package.
|
// state package.
|
||||||
package anomaly
|
package anomaly
|
||||||
@@ -28,16 +28,16 @@ const maxCounters = 20000
|
|||||||
// alerts name them.
|
// alerts name them.
|
||||||
const (
|
const (
|
||||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
||||||
// SWWAF_IPV6_GROUP_PREFIX.
|
// SWW_IPV6_GROUP_PREFIX.
|
||||||
ScopeClient = "client"
|
ScopeClient = "client"
|
||||||
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX
|
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
|
||||||
// or SWWAF_ANOMALY_NET_V6_PREFIX long.
|
// or SWW_ANOMALY_NET_V6_PREFIX long.
|
||||||
ScopeNet = "net"
|
ScopeNet = "net"
|
||||||
// ScopeASN is an AS number.
|
// ScopeASN is an AS number.
|
||||||
ScopeASN = "asn"
|
ScopeASN = "asn"
|
||||||
// ScopeTotal is the whole service.
|
// ScopeTotal is the whole service.
|
||||||
ScopeTotal = "total"
|
ScopeTotal = "total"
|
||||||
// ScopeWatch is a named netblock of SWWAF_WATCH_NETS.
|
// ScopeWatch is a named netblock of SWW_WATCH_NETS.
|
||||||
ScopeWatch = "watch"
|
ScopeWatch = "watch"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ type Thresholds struct {
|
|||||||
BytesPerHour int64
|
BytesPerHour int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NamedNetblock is a netblock SWWAF_WATCH_NETS names.
|
// NamedNetblock is a netblock SWW_WATCH_NETS names.
|
||||||
type NamedNetblock struct {
|
type NamedNetblock struct {
|
||||||
Name string
|
Name string
|
||||||
Netblock netip.Prefix
|
Netblock netip.Prefix
|
||||||
@@ -70,14 +70,14 @@ type NamedNetblock struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// The thresholds of each scope: SWWAF_ANOMALY_CLIENT_*,
|
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
|
||||||
// SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and
|
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
|
||||||
// SWWAF_WATCH_*.
|
// SWW_WATCH_*.
|
||||||
Client, Net, ASN, Total, Watch Thresholds
|
Client, Net, ASN, Total, Watch Thresholds
|
||||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||||
// client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX).
|
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
|
||||||
NetV4Prefix, NetV6Prefix int
|
NetV4Prefix, NetV6Prefix int
|
||||||
// NamedNetblocks are SWWAF_WATCH_NETS.
|
// NamedNetblocks are SWW_WATCH_NETS.
|
||||||
NamedNetblocks []NamedNetblock
|
NamedNetblocks []NamedNetblock
|
||||||
// Alerts receive the anomaly alerts.
|
// Alerts receive the anomaly alerts.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
@@ -103,15 +103,17 @@ type Counter struct {
|
|||||||
|
|
||||||
// Request is a request that has ended, as the counters count it.
|
// Request is a request that has ended, as the counters count it.
|
||||||
type Request struct {
|
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
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||||
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
// SWW_IPV6_GROUP_PREFIX its address is in.
|
||||||
Client netip.Addr
|
Client netip.Addr
|
||||||
ClientGroup netip.Prefix
|
ClientGroup netip.Prefix
|
||||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||||
// unknown.
|
// unknown.
|
||||||
ASN, ASName, Country string
|
ASN, ASName, Country string
|
||||||
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them.
|
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
|
||||||
Bytes int64
|
Bytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +398,7 @@ func alertFor(r Request, k key, o overThreshold) alerts.Alert {
|
|||||||
|
|
||||||
return alerts.Alert{
|
return alerts.Alert{
|
||||||
Event: alerts.EventAnomaly,
|
Event: alerts.EventAnomaly,
|
||||||
|
Host: r.Host,
|
||||||
Client: r.Client,
|
Client: r.Client,
|
||||||
Netblock: k.netblock,
|
Netblock: k.netblock,
|
||||||
ASN: r.ASN,
|
ASN: r.ASN,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
third := netip.MustParsePrefix("203.0.113.3/32")
|
third := netip.MustParsePrefix("203.0.113.3/32")
|
||||||
|
|
||||||
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
||||||
// then one with an admin's ban before a ban smallwebwaf made: the one
|
// then one with an admin's ban before a ban sww made: the one
|
||||||
// ban counted toward MaxBans.
|
// ban counted toward MaxBans.
|
||||||
ledger.Load([]bans.Ban{
|
ledger.Load([]bans.Ban{
|
||||||
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
||||||
@@ -48,7 +48,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
})
|
})
|
||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
||||||
|
|
||||||
// A new ban drops the ban smallwebwaf made, and only that one.
|
// A new ban drops the ban sww made, and only that one.
|
||||||
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
func TestReasonOfTheBansSwwMakes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultRules())
|
||||||
@@ -226,7 +226,7 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
|||||||
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin keeps the ban smallwebwaf made, keeps the one read at the
|
// The admin keeps the ban sww made, keeps the one read at the
|
||||||
// start, and adds one without a cause: that one alone is made.
|
// start, and adds one without a cause: that one alone is made.
|
||||||
kept := made
|
kept := made
|
||||||
kept.Cause = bans.CauseAdmin
|
kept.Cause = bans.CauseAdmin
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
// Package bans is the ban ledger: the bans sww makes on the
|
||||||
// netblocks of clients that break a rate limit, a byte limit or the error
|
// netblocks of clients that break a rate limit, a byte limit or the error
|
||||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
// burst, show a clear sign of attack or are listed by the CrowdSec
|
||||||
// decision list, and
|
// decision list, and
|
||||||
@@ -21,14 +21,14 @@ import (
|
|||||||
|
|
||||||
// The causes of bans.
|
// The causes of bans.
|
||||||
const (
|
const (
|
||||||
// CauseLimit is a ban smallwebwaf made for a broken limit.
|
// CauseLimit is a ban sww made for a broken limit.
|
||||||
CauseLimit = "limit"
|
CauseLimit = "limit"
|
||||||
// CauseAttack is a ban smallwebwaf made for a clear sign of attack.
|
// CauseAttack is a ban sww made for a clear sign of attack.
|
||||||
CauseAttack = "attack"
|
CauseAttack = "attack"
|
||||||
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
// CauseAdmin is a ban an admin made, or one sww made that an
|
||||||
// admin keeps. It is never dropped.
|
// admin keeps. It is never dropped.
|
||||||
CauseAdmin = "admin"
|
CauseAdmin = "admin"
|
||||||
// CauseCrowdSec is a ban smallwebwaf made for a client the CrowdSec
|
// CauseCrowdSec is a ban sww made for a client the CrowdSec
|
||||||
// decision list lists. It ends when CrowdSec's decision does.
|
// decision list lists. It ends when CrowdSec's decision does.
|
||||||
CauseCrowdSec = "crowdsec"
|
CauseCrowdSec = "crowdsec"
|
||||||
)
|
)
|
||||||
@@ -70,7 +70,7 @@ type Ban struct {
|
|||||||
Expires time.Time
|
Expires time.Time
|
||||||
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
||||||
Cause string
|
Cause string
|
||||||
// Reason is a short text: for a ban smallwebwaf made, the limit broken,
|
// Reason is a short text: for a ban sww made, the limit broken,
|
||||||
// the rule that matched or the scenario of CrowdSec's decision; for an
|
// the rule that matched or the scenario of CrowdSec's decision; for an
|
||||||
// admin's, what the admin wrote.
|
// admin's, what the admin wrote.
|
||||||
Reason string
|
Reason string
|
||||||
@@ -123,7 +123,7 @@ type Notes struct {
|
|||||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||||
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
||||||
// of the rule file rule that matched, and its target; TrapPath is, for
|
// of the rule file rule that matched, and its target; TrapPath is, for
|
||||||
// one for a request for a path in SWWAF_TRAP_PATHS, that path as the
|
// one for a request for a path in SWW_TRAP_PATHS, that path as the
|
||||||
// client sent it.
|
// client sent it.
|
||||||
RuleID string `json:"rule_id,omitempty"`
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
@@ -199,7 +199,7 @@ type Ledger struct {
|
|||||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||||
// netblocks that have been banned. Check looks for a ban at each of
|
// netblocks that have been banned. Check looks for a ban at each of
|
||||||
// them, so that a ban read from bans.json refuses every client in its
|
// them, so that a ban read from bans.json refuses every client in its
|
||||||
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
|
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
|
||||||
// or another length of an IPv6 client's netblock.
|
// or another length of an IPv6 client's netblock.
|
||||||
v4Lengths, v6Lengths []int
|
v4Lengths, v6Lengths []int
|
||||||
}
|
}
|
||||||
@@ -581,7 +581,7 @@ func (l *Ledger) Load(bans []Ban) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
||||||
// smallwebwaf runs. Each ban in it whose cause is CauseAdmin, and which
|
// sww runs. Each ban in it whose cause is CauseAdmin, and which
|
||||||
// the ledger did not hold, with the same netblock and start, is one the
|
// the ledger did not hold, with the same netblock and start, is one the
|
||||||
// admin made, and is counted among the bans made.
|
// admin made, and is counted among the bans made.
|
||||||
func (l *Ledger) LoadEdit(bans []Ban) {
|
func (l *Ledger) LoadEdit(bans []Ban) {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
|||||||
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's
|
// A 9-hour ban sww made, the third in a row, and an admin's
|
||||||
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
nineHours := bans.Ban{
|
nineHours := bans.Ban{
|
||||||
|
|||||||
+763
-369
File diff suppressed because it is too large.
Load diff
+512
-334
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,192 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
|
||||||
|
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
|
||||||
|
// the score, when its score of the client is a hit, and refuses the request
|
||||||
|
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
|
||||||
|
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
|
||||||
|
// checked in the background, by the request's address, if its history counts
|
||||||
|
// an offence, and the request does not wait for the answer. The score is then
|
||||||
|
// used for each address of the client. Its figures are the score and
|
||||||
|
// SWW_ABUSEIPDB_MIN_SCORE.
|
||||||
|
type abuseIPDB struct{}
|
||||||
|
|
||||||
|
func (abuseIPDB) name() string { return "abuseipdb" }
|
||||||
|
|
||||||
|
func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
if rq.h.config.AbuseIPDBKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client := rq.h.clientGroup(rq.client)
|
||||||
|
held, _ := rq.h.limiter.Client(client)
|
||||||
|
offender := held.History.Offences != ratelimit.Offences{}
|
||||||
|
|
||||||
|
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
|
||||||
|
if !hit {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.abuseIPDBHit = true
|
||||||
|
rq.addTag(config.AbuseIPDBTag)
|
||||||
|
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||||
|
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
||||||
|
|
||||||
|
if rq.h.config.ReputationAction != deny {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionDenied,
|
||||||
|
figures: map[string]any{
|
||||||
|
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,16 +40,22 @@ var (
|
|||||||
errNotAddress = errors.New("is not an address, such as 203.0.113.9")
|
errNotAddress = errors.New("is not an address, such as 203.0.113.9")
|
||||||
)
|
)
|
||||||
|
|
||||||
// answerAdmin answers a request for smallwebwaf itself, under
|
// answerHealth answers a request for the health endpoint.
|
||||||
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
func (rq *request) answerHealth() {
|
||||||
// token, sent as Authorization: Bearer <token>: the metrics
|
rq.line.Action = requestlog.ActionAdmin
|
||||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without
|
// Set here rather than left to Go's server, which would set it only
|
||||||
// it is refused with 401, which counts toward the error burst. An
|
// after the log line has taken the response's headers.
|
||||||
// endpoint whose token is unset answers 404, as any other request under
|
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
// /_smallwebwaf/ does.
|
_, _ = 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() {
|
func (rq *request) answerAdmin() {
|
||||||
rq.line.Action = requestlog.ActionAdmin
|
rq.line.Action = requestlog.ActionAdmin
|
||||||
rq.startClientResponseTimeout()
|
|
||||||
|
|
||||||
token, answer := rq.endpoint()
|
token, answer := rq.endpoint()
|
||||||
|
|
||||||
@@ -58,12 +63,8 @@ func (rq *request) answerAdmin() {
|
|||||||
case token == "":
|
case token == "":
|
||||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||||
case !hasToken(rq.in, token):
|
case !hasToken(rq.in, token):
|
||||||
rq.tokenRefused = true
|
|
||||||
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
rq.answer(refusal{
|
http.Error(rq.out, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||||
status: http.StatusUnauthorized,
|
|
||||||
action: requestlog.ActionAdmin,
|
|
||||||
})
|
|
||||||
default:
|
default:
|
||||||
answer()
|
answer()
|
||||||
}
|
}
|
||||||
@@ -120,26 +121,7 @@ type banToAdd struct {
|
|||||||
// an admin, from now for the duration the body gives, with its reason,
|
// an admin, from now for the duration the body gives, with its reason,
|
||||||
// and answers with that ban.
|
// and answers with that ban.
|
||||||
func (rq *request) addBan() {
|
func (rq *request) addBan() {
|
||||||
// The body must arrive within SWWAF_CLIENT_REQUEST_TIMEOUT, as any
|
|
||||||
// other request's must.
|
|
||||||
rq.stopReadingBody(rq.clientRequestDeadline())
|
|
||||||
|
|
||||||
toAdd, err := rq.readBanToAdd()
|
toAdd, err := rq.readBanToAdd()
|
||||||
if refused := rq.refused.Load(); refused != nil {
|
|
||||||
rq.answer(*refused) // the body is over SWWAF_REQUEST_MAX_BYTES
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
|
||||||
rq.answer(refusal{
|
|
||||||
status: http.StatusRequestTimeout,
|
|
||||||
action: requestlog.ActionTimedOut,
|
|
||||||
limit: "SWWAF_CLIENT_REQUEST_TIMEOUT",
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
netblock netip.Prefix
|
netblock netip.Prefix
|
||||||
@@ -168,12 +150,9 @@ func (rq *request) addBan() {
|
|||||||
// readBanToAdd reads the body of POST BansPath: a JSON object with
|
// readBanToAdd reads the body of POST BansPath: a JSON object with
|
||||||
// nothing but whitespace after it, in at most banBodyMaxBytes.
|
// nothing but whitespace after it, in at most banBodyMaxBytes.
|
||||||
func (rq *request) readBanToAdd() (banToAdd, error) {
|
func (rq *request) readBanToAdd() (banToAdd, error) {
|
||||||
var body io.ReadCloser = http.NoBody
|
data, err := io.ReadAll(http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes))
|
||||||
if rq.body != nil {
|
rq.requestBytes = int64(len(data))
|
||||||
body = rq.body
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(http.MaxBytesReader(nil, body, banBodyMaxBytes))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
|
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
|
||||||
}
|
}
|
||||||
@@ -271,9 +250,9 @@ type clientAnswer struct {
|
|||||||
Bans []state.BanEntry `json:"bans"`
|
Bans []state.BanEntry `json:"bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// showClient answers GET ClientsPath<ip> with what smallwebwaf knows of
|
// showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
|
||||||
// the client: its counters, its history, which holds its country as last
|
// its counters, its history, which holds its country as last looked up and its
|
||||||
// looked up and its offences, and its bans with their notes.
|
// offences, and its bans with their notes.
|
||||||
func (rq *request) showClient() {
|
func (rq *request) showClient() {
|
||||||
addr, err := pathAddress(rq.in.URL.Path, ClientsPath)
|
addr, err := pathAddress(rq.in.URL.Path, ClientsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer
|
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
|
||||||
// how a request carries it.
|
// how a request carries it.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
adminBearer = "Bearer " + adminSecret
|
adminBearer = "Bearer " + adminSecret
|
||||||
// adminClient is the client the tests' admin sends its requests from.
|
|
||||||
adminClient = "192.0.2.10"
|
|
||||||
// banOtherClient is the body of a request to ban otherClient for an
|
// banOtherClient is the body of a request to ban otherClient for an
|
||||||
// hour.
|
// hour.
|
||||||
banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` +
|
banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` +
|
||||||
@@ -43,8 +40,7 @@ func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
|||||||
// An empty token does not match the unset one either.
|
// An empty token does not match the unset one either.
|
||||||
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
||||||
for _, e := range adminEndpoints() {
|
for _, e := range adminEndpoints() {
|
||||||
s.adminRequest(adminClient, authorization, e.method, e.path, e.body,
|
s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
|
||||||
http.StatusNotFound, requestlog.ActionAdmin)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +67,8 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
|||||||
for _, authorization := range []string{
|
for _, authorization := range []string{
|
||||||
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
||||||
} {
|
} {
|
||||||
got := s.adminRequest(adminClient, authorization, e.method, e.path, e.body,
|
got := s.adminRequest(authorization, e.method, e.path, e.body,
|
||||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
http.StatusUnauthorized)
|
||||||
if got.header.Get("WWW-Authenticate") != "Bearer" {
|
if got.header.Get("WWW-Authenticate") != "Bearer" {
|
||||||
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
|
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
|
||||||
e.method, e.path, authorization)
|
e.method, e.path, authorization)
|
||||||
@@ -90,13 +86,13 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any other request under /_smallwebwaf/ is not found.
|
// Any other request under /_sww/ is not found.
|
||||||
for _, e := range []adminEndpoint{
|
for _, e := range []adminEndpoint{
|
||||||
{http.MethodPut, proxy.BansPath, banOtherClient},
|
{http.MethodPut, discriminator.BansPath, banOtherClient},
|
||||||
{http.MethodDelete, proxy.BansPath, ""},
|
{http.MethodDelete, discriminator.BansPath, ""},
|
||||||
{http.MethodGet, proxy.BansPath + "/" + otherClient, ""},
|
{http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
|
||||||
{http.MethodPost, proxy.ClientsPath + otherClient, ""},
|
{http.MethodPost, discriminator.ClientsPath + otherClient, ""},
|
||||||
{http.MethodGet, strings.TrimSuffix(proxy.ClientsPath, "/"), ""},
|
{http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
|
||||||
} {
|
} {
|
||||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -122,8 +118,8 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
Reason: "probes for logins",
|
Reason: "probes for logins",
|
||||||
}
|
}
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK),
|
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
||||||
want)
|
http.StatusOK), want)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||||
@@ -133,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
// Its notes count the request it refused.
|
// Its notes count the request it refused.
|
||||||
want.Notes.Requests, want.Notes.Refused = 1, 1
|
want.Notes.Requests, want.Notes.Refused = 1, 1
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||||
|
|
||||||
// Ten minutes on, lifting the bans on client lifts that one, which is
|
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||||
// kept, marked lifted.
|
// kept, marked lifted.
|
||||||
@@ -142,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
lifted := clk.Now()
|
lifted := clk.Now()
|
||||||
want.Lifted = &lifted
|
want.Lifted = &lifted
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "",
|
wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
|
||||||
http.StatusOK), want)
|
http.StatusOK), want)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantBans(t, s.admin(http.MethodGet, proxy.BansPath, "", http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||||
|
|
||||||
// No ban on it is active any more.
|
// No ban on it is active any more.
|
||||||
s.admin(http.MethodDelete, proxy.BansPath+"/"+client, "", http.StatusNotFound)
|
s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||||
@@ -185,7 +181,8 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
|||||||
want.Expires = &expires
|
want.Expires = &expires
|
||||||
}
|
}
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodPost, proxy.BansPath, body, http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
|
||||||
|
want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +251,8 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
|||||||
"request body too large",
|
"request body too large",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
got := s.admin(http.MethodPost, proxy.BansPath, tc.body, http.StatusBadRequest)
|
got := s.admin(http.MethodPost, discriminator.BansPath, tc.body,
|
||||||
|
http.StatusBadRequest)
|
||||||
if !strings.Contains(string(got.body), tc.want) {
|
if !strings.Contains(string(got.body), tc.want) {
|
||||||
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
||||||
}
|
}
|
||||||
@@ -265,64 +263,12 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanToAddOverTheRequestSizeLimitIsRefused(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
|
||||||
adminToken: adminSecret,
|
|
||||||
requestMaxBytes: "16",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sent in a chunk, its length is not announced, so that it is found
|
|
||||||
// over SWWAF_REQUEST_MAX_BYTES only as it is read.
|
|
||||||
chunk := `{"netblock": "203.0.113.9", "duration": "1h"}`
|
|
||||||
s.adminRequest(adminClient, adminBearer+"\r\nTransfer-Encoding: chunked",
|
|
||||||
http.MethodPost, proxy.BansPath,
|
|
||||||
strconv.FormatInt(int64(len(chunk)), 16)+"\r\n"+chunk+"\r\n0\r\n\r\n",
|
|
||||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
|
|
||||||
|
|
||||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
|
||||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBanToAddSlowerThanTheClientRequestTimeoutIsRefused(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
|
||||||
adminToken: adminSecret,
|
|
||||||
metricsToken: token,
|
|
||||||
clientRequestTimeout: shortTimeoutSetting,
|
|
||||||
})
|
|
||||||
|
|
||||||
// The chunk announces 256 bytes and the rest of it never comes, so only
|
|
||||||
// the timeout ends the wait. A hold-up of the test process can only
|
|
||||||
// make the answer later, so the time is checked only for not being
|
|
||||||
// shorter than the timeout.
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
s.adminRequest(adminClient, adminBearer+"\r\nTransfer-Encoding: chunked",
|
|
||||||
http.MethodPost, proxy.BansPath, "100\r\n"+`{"netblock": "203.0.113.9", `,
|
|
||||||
http.StatusRequestTimeout, requestlog.ActionTimedOut)
|
|
||||||
|
|
||||||
if took := time.Since(start); took < shortTimeout {
|
|
||||||
t.Errorf("answered after %s, before the timeout of %s ran out", took, shortTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantLimitHits(t, s.addr, clientRequestTimeout, 1)
|
|
||||||
|
|
||||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
|
||||||
t.Errorf("the ledger holds %+v, want no ban", held)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
adminToken: adminSecret,
|
adminToken: adminSecret,
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
rateLimitExemptNets: adminClient,
|
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -339,7 +285,8 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
Bans []state.BanEntry `json:"bans"`
|
Bans []state.BanEntry `json:"bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
decode(t, s.admin(http.MethodGet, proxy.ClientsPath+addr, "", http.StatusOK), &got)
|
decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
|
||||||
|
http.StatusOK), &got)
|
||||||
|
|
||||||
if got.Client == nil {
|
if got.Client == nil {
|
||||||
t.Fatalf("%s: no client", addr)
|
t.Fatalf("%s: no client", addr)
|
||||||
@@ -361,69 +308,16 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
|
|
||||||
// Of an address no request came from and no ban covers, nothing is
|
// Of an address no request came from and no ban covers, nothing is
|
||||||
// known.
|
// known.
|
||||||
got := s.admin(http.MethodGet, proxy.ClientsPath+"198.51.100.99", "", http.StatusOK)
|
got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "",
|
||||||
|
http.StatusOK)
|
||||||
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
||||||
t.Errorf("an unknown client is answered\n%s", got.body)
|
t.Errorf("an unknown client is answered\n%s", got.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.admin(http.MethodGet, proxy.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "",
|
||||||
s.admin(http.MethodDelete, proxy.BansPath+"/203.0.113.0/24", "",
|
http.StatusBadRequest)
|
||||||
|
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
}
|
|
||||||
|
|
||||||
func TestBannedClientIsRefusedAtTheEndpointsEvenWithTheToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{adminToken: adminSecret})
|
|
||||||
|
|
||||||
s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK)
|
|
||||||
|
|
||||||
// otherClient cannot lift its own ban either.
|
|
||||||
for _, e := range adminEndpoints() {
|
|
||||||
s.adminRequest(otherClient, adminBearer, e.method, e.path, e.body,
|
|
||||||
http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminRequestsCountTowardTheLimits(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
adminToken: adminSecret,
|
|
||||||
rateLimitPerMinute: "2",
|
|
||||||
})
|
|
||||||
|
|
||||||
// A request refused for a missing token and one answered count toward
|
|
||||||
// the limit of two a minute, so the next breaks it.
|
|
||||||
s.adminRequest(client, "", http.MethodGet, proxy.BansPath, "",
|
|
||||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
|
||||||
s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "",
|
|
||||||
http.StatusOK, requestlog.ActionAdmin)
|
|
||||||
s.adminRequest(client, adminBearer, http.MethodGet, proxy.BansPath, "",
|
|
||||||
http.StatusForbidden, requestlog.ActionRateLimited)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientInAllowNetsSkipsTheChecksButNeedsTheToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
|
||||||
|
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
|
||||||
adminToken: adminSecret,
|
|
||||||
allowNets: allowed,
|
|
||||||
rateLimitPerMinute: "1",
|
|
||||||
})
|
|
||||||
|
|
||||||
// A ban on it refuses nothing, and its requests are not counted.
|
|
||||||
server.Ledger.BanForAdmin(netip.MustParsePrefix(allowed+"/32"), clk.Now(),
|
|
||||||
time.Time{}, "")
|
|
||||||
|
|
||||||
for range 2 {
|
|
||||||
s.adminRequest(allowed, "", http.MethodGet, proxy.BansPath, "",
|
|
||||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
|
||||||
s.adminRequest(allowed, adminBearer, http.MethodGet, proxy.BansPath, "",
|
|
||||||
http.StatusOK, requestlog.ActionAdmin)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
||||||
@@ -435,8 +329,7 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
for _, e := range adminEndpoints() {
|
for _, e := range adminEndpoints() {
|
||||||
s.adminRequest(adminClient, "", e.method, e.path, e.body,
|
s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
||||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
@@ -444,56 +337,50 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens.
|
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
|
||||||
type adminEndpoint struct {
|
type adminEndpoint struct {
|
||||||
method, path, body string
|
method, path, body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN
|
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
|
||||||
// opens: listing the bans, banning otherClient for an hour, lifting the
|
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||||
// bans on otherClient, and asking about otherClient.
|
// bans on otherClient, and asking about otherClient.
|
||||||
func adminEndpoints() []adminEndpoint {
|
func adminEndpoints() []adminEndpoint {
|
||||||
return []adminEndpoint{
|
return []adminEndpoint{
|
||||||
{http.MethodGet, proxy.BansPath, ""},
|
{http.MethodGet, discriminator.BansPath, ""},
|
||||||
{http.MethodPost, proxy.BansPath, banOtherClient},
|
{http.MethodPost, discriminator.BansPath, banOtherClient},
|
||||||
{http.MethodDelete, proxy.BansPath + "/" + otherClient, ""},
|
{http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
|
||||||
{http.MethodGet, proxy.ClientsPath + otherClient, ""},
|
{http.MethodGet, discriminator.ClientsPath + otherClient, ""},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// admin sends a request with method for path, with body, from
|
// admin sends a request with method for path, with body, to discriminator's own
|
||||||
// adminClient, with the admin token, and checks that it is answered with
|
// address, with the admin token, and checks that it is answered with status, its
|
||||||
// status, its log line's action admin. It returns the answer.
|
// log line's action admin. It returns the answer.
|
||||||
func (s *sender) admin(method, path, body string, status int) answer {
|
func (s *sender) admin(method, path, body string, status int) answer {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
return s.adminRequest(adminClient, adminBearer, method, path, body, status,
|
return s.adminRequest(adminBearer, method, path, body, status)
|
||||||
requestlog.ActionAdmin)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminRequest sends a request with method for path, with body, from the
|
// adminRequest is admin with authorization as the request's Authorization header
|
||||||
// client at from, with authorization as its Authorization header unless
|
// unless it is "".
|
||||||
// it is "", and checks its answer's status and its log line's action, as
|
|
||||||
// request does. authorization may end in more header lines. A body that
|
|
||||||
// is not "" has its length announced, unless authorization names
|
|
||||||
// Transfer-Encoding. It returns the answer.
|
|
||||||
func (s *sender) adminRequest(
|
func (s *sender) adminRequest(
|
||||||
from, authorization, method, path, body string, status int, action string,
|
authorization, method, path, body string, status int,
|
||||||
) answer {
|
) answer {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
var header []string
|
req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
|
||||||
|
|
||||||
if authorization != "" {
|
if authorization != "" {
|
||||||
header = append(header, "Authorization: "+authorization)
|
req.Header.Set("Authorization", authorization)
|
||||||
}
|
}
|
||||||
|
|
||||||
if body != "" && !strings.Contains(authorization, "Transfer-Encoding") {
|
got := do(s.t, req)
|
||||||
header = append(header, "Content-Length: "+strconv.Itoa(len(body)))
|
wantStatus(s.t, got, status)
|
||||||
}
|
|
||||||
|
|
||||||
_, got := s.requestWithBody(method, from, path, strings.Join(header, "\r\n"),
|
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||||
body, status, action)
|
s.sent++
|
||||||
|
wantLine(s.t, line, status, requestlog.ActionAdmin)
|
||||||
|
|
||||||
return got
|
return got
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||||
// alertInstance is the instance every alert of these tests gives.
|
// alertInstance is the instance every alert of these tests gives.
|
||||||
alertInstance = "fsn1app1/gitea"
|
alertInstance = "fsn1app1/blog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
||||||
@@ -201,7 +201,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
|||||||
// never sent them, and returns the queue they wait in as well.
|
// never sent them, and returns the queue they wait in as well.
|
||||||
func startWithAlerts(
|
func startWithAlerts(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||||
@@ -210,21 +210,23 @@ func startWithAlerts(
|
|||||||
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||||
func startAppWithAlerts(
|
func startAppWithAlerts(
|
||||||
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||||
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := startApp(t, handler)
|
app := startApp(t, handler)
|
||||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
alertWebhookURL: "https://alerts.example/sww",
|
||||||
instanceName: alertInstance,
|
instanceName: alertInstance,
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
||||||
|
|
||||||
return &sender{t: t, addr: addr, out: out}, clk, server, queue
|
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
|
// banAlert returns the alert for event, raised by a request from client at
|
||||||
@@ -237,6 +239,7 @@ func banAlert(
|
|||||||
Instance: alertInstance,
|
Instance: alertInstance,
|
||||||
Time: raised,
|
Time: raised,
|
||||||
Event: event,
|
Event: event,
|
||||||
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(client),
|
Client: netip.MustParseAddr(client),
|
||||||
Netblock: ban.Netblock,
|
Netblock: ban.Netblock,
|
||||||
Reason: ban.Reason,
|
Reason: ban.Reason,
|
||||||
+4
-2
@@ -1,4 +1,4 @@
|
|||||||
package proxy
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -15,9 +15,11 @@ func TestWithEveryAnomalyThresholdOffARequestIsNotCounted(t *testing.T) {
|
|||||||
// anomaly counters, nor a clock, and the request no response: reading
|
// anomaly counters, nor a clock, and the request no response: reading
|
||||||
// any of them to count the request panics.
|
// any of them to count the request panics.
|
||||||
rq := &request{
|
rq := &request{
|
||||||
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
decision: decision{
|
||||||
client: netip.MustParseAddr("203.0.113.9"),
|
client: netip.MustParseAddr("203.0.113.9"),
|
||||||
lookedUp: true,
|
lookedUp: true,
|
||||||
|
},
|
||||||
|
h: &handler{config: &config.Config{LookupSource: "geojs"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
@@ -21,11 +21,11 @@ import (
|
|||||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||||
// count.
|
// count.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWWAF_ANOMALY_NET_"
|
anomalyNet = "SWW_ANOMALY_NET_"
|
||||||
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||||
anomalyWatch = "SWWAF_WATCH_"
|
anomalyWatch = "SWW_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -35,9 +35,9 @@ const (
|
|||||||
|
|
||||||
// The other anomaly settings.
|
// The other anomaly settings.
|
||||||
const (
|
const (
|
||||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||||
watchNets = "SWWAF_WATCH_NETS"
|
watchNets = "SWW_WATCH_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -117,6 +117,7 @@ func TestEachScopeAndWindowOverItsThresholdAlertsOncePerCooldown(t *testing.T) {
|
|||||||
Instance: alertInstance,
|
Instance: alertInstance,
|
||||||
Time: start,
|
Time: start,
|
||||||
Event: alerts.EventAnomaly,
|
Event: alerts.EventAnomaly,
|
||||||
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(client),
|
Client: netip.MustParseAddr(client),
|
||||||
Netblock: scope.netblock,
|
Netblock: scope.netblock,
|
||||||
ASN: asnDE,
|
ASN: asnDE,
|
||||||
@@ -163,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
denied = "192.0.2.20" // in SWWAF_DENY_NETS
|
denied = "192.0.2.20" // in SWW_DENY_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
@@ -318,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
|||||||
// kept.
|
// kept.
|
||||||
geojsURL, _, release := startHeldGeoJS(t)
|
geojsURL, _, release := startHeldGeoJS(t)
|
||||||
|
|
||||||
var server atomic.Pointer[proxy.Server]
|
var server atomic.Pointer[discriminator.Server]
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
release()
|
release()
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
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 unansweredGeoJSURL names too.
|
||||||
|
silentAbuseIPDBURL = "unanswered://abuseipdb/api/v2/check"
|
||||||
|
// wholeLimit is the percentage of a rate limit that lowers it not at all.
|
||||||
|
wholeLimit = 100
|
||||||
|
// clientsDecision is the CrowdSec decision list holding client alone.
|
||||||
|
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.ActionTooLarge)
|
||||||
|
|
||||||
|
if line.DurationWAF != nil {
|
||||||
|
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startDiscriminator(t, "", nil)
|
||||||
|
req := s.question(siteA, client, "/")
|
||||||
|
fillHeaders(req, headerFieldLimit+1)
|
||||||
|
do(t, req)
|
||||||
|
|
||||||
|
wantHeuristic(t, s.out.requestLine(t), "header_limits",
|
||||||
|
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
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.ActionTooLarge)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
if line.DurationWAF == 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.ActionTooLarge || line.DurationWAF != nil {
|
||||||
|
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
||||||
|
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
|
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.ActionRateLimited)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, path, action string
|
||||||
|
}{
|
||||||
|
{"a trap path", "/wp-login.php", requestlog.ActionBanned},
|
||||||
|
{"a block rule", "/blocked", requestlog.ActionRuleBlocked},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := startDiscriminator(t, "", map[string]string{
|
||||||
|
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.ActionTooLarge)
|
||||||
|
|
||||||
|
if line.LimitPercent != nil {
|
||||||
|
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client is under no ban, and GeoJS places it in a country.
|
||||||
|
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.ActionRateLimited)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, unansweredGeoJSURL, 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.ActionDenied,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a ban",
|
||||||
|
prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
||||||
|
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the rate limits, with a biased threshold",
|
||||||
|
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||||||
|
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
||||||
|
prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the CrowdSec decision list",
|
||||||
|
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
||||||
|
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
||||||
|
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
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,7 +1,10 @@
|
|||||||
package proxy
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic active_ban, and the code that makes bans, which several
|
||||||
|
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"context"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,17 +15,21 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
|
||||||
// with action.
|
// with action.
|
||||||
func (rq *request) banResponse(action string) *refusal {
|
func (rq *request) banResponse(action string) *refusal {
|
||||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||||
}
|
}
|
||||||
|
|
||||||
// banned reports whether a ban on a netblock the client is in covers the
|
// activeBan refuses a client a ban covers: a ban on a netblock the client is
|
||||||
// request at now, and notes for the log line when that ban ends. A
|
// in, in force at now. It notes for the log line when that ban ends. A request
|
||||||
// request that makes the ban permanent, or in observe mode would have,
|
// that makes the ban permanent, or in observe mode would have, raises the alert
|
||||||
// raises the alert for it.
|
// for it. Its figures are the ban's netblock and when it ends.
|
||||||
func (rq *request) banned(now time.Time) bool {
|
type activeBan struct{}
|
||||||
|
|
||||||
|
func (activeBan) name() string { return "active_ban" }
|
||||||
|
|
||||||
|
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||||
check := rq.h.ledger.Check
|
check := rq.h.ledger.Check
|
||||||
if rq.h.config.Observe {
|
if rq.h.config.Observe {
|
||||||
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
||||||
@@ -38,105 +45,31 @@ func (rq *request) banned(now time.Time) bool {
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banned
|
if !banned {
|
||||||
}
|
return nil
|
||||||
|
|
||||||
// limitBroken counts the request for the rate limits at now, notes the
|
|
||||||
// client's counts for the log line, and reports whether the request takes
|
|
||||||
// the client over a rate limit, as its limit percentage lowers it, which
|
|
||||||
// breaks it.
|
|
||||||
func (rq *request) limitBroken(now time.Time) bool {
|
|
||||||
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
|
||||||
rq.limitPercent.percent)
|
|
||||||
rq.line.Counts = counts
|
|
||||||
|
|
||||||
if over {
|
|
||||||
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return over
|
return &verdict{
|
||||||
}
|
action: requestlog.ActionBanned,
|
||||||
|
figures: map[string]any{
|
||||||
// countBytes counts the request's bytes, as countedBytes gives them, for
|
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
||||||
// the byte limits, once its response has ended, and notes the client's
|
},
|
||||||
// byte totals for the log line; its requests stay there as the rate limits
|
|
||||||
// counted them. Only a request passed to the app has them counted, and
|
|
||||||
// only one the rate limits counted; in observe mode, not one that enforce
|
|
||||||
// mode would have refused. Bytes that take the client over a byte limit,
|
|
||||||
// as its limit percentage for the byte limits lowers it, break it; the
|
|
||||||
// response was passed on whole.
|
|
||||||
func (rq *request) countBytes() {
|
|
||||||
if !rq.counted || rq.line.WouldAction != "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := rq.h.now()
|
|
||||||
|
|
||||||
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
|
||||||
rq.countedBytes(), rq.bytesPercent.percent)
|
|
||||||
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
|
||||||
rq.line.Counts.HourBytes = counts.HourBytes
|
|
||||||
rq.line.Counts.DayBytes = counts.DayBytes
|
|
||||||
|
|
||||||
if over {
|
|
||||||
rq.banForLimit(now, hit, rq.out.status)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// countRefusal counts the request for the error burst once it has been
|
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
||||||
// answered, if smallwebwaf refused it after a rule file match, a trap path
|
// window, the count and the limit.
|
||||||
// or a Core Rule Set match, or for a missing or wrong token, and in
|
func limitFigures(hit ratelimit.Hit) map[string]any {
|
||||||
// observe mode if enforce mode would have: more than
|
return map[string]any{
|
||||||
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
|
||||||
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
|
|
||||||
// not counted, and nothing is while the threshold is off.
|
|
||||||
func (rq *request) countRefusal() {
|
|
||||||
cfg := rq.h.config
|
|
||||||
if cfg.ErrorBurstThreshold == 0 {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// In observe mode, a request that enforce mode would have refused
|
|
||||||
// before it reached the endpoint has had no token refused there.
|
|
||||||
tokenRefused := rq.tokenRefused && rq.line.WouldAction == "" &&
|
|
||||||
!isInside(rq.client, cfg.AllowNets)
|
|
||||||
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked && !tokenRefused {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
now := rq.h.now()
|
|
||||||
|
|
||||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
|
||||||
cfg.ErrorBurstThreshold)
|
|
||||||
if !over {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the client was sent, or in observe mode would have been.
|
|
||||||
status := rq.out.status
|
|
||||||
|
|
||||||
switch rq.line.WouldAction {
|
|
||||||
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
|
|
||||||
status = http.StatusForbidden
|
|
||||||
case requestlog.ActionBanned:
|
|
||||||
status = cfg.BanResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.banForLimit(now, hit, status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// countedBytes returns the request's bytes, once it has ended, as the
|
// countedBytes returns the request's bytes, once it has ended, as the
|
||||||
// byte limits and the anomaly thresholds count them: the response's body
|
// byte limits and the anomaly thresholds count them: the response's body
|
||||||
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says. For an
|
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
|
||||||
// upgraded connection, such as a WebSocket, which has closed by then, what
|
|
||||||
// it carried from the app counts with the response's and what it carried
|
|
||||||
// from the client with the request's.
|
|
||||||
func (rq *request) countedBytes() int64 {
|
func (rq *request) countedBytes() int64 {
|
||||||
response, request := rq.out.bytes, rq.requestBytes()
|
response, request := rq.out.bytes, rq.requestBytes
|
||||||
if rq.upgraded != nil {
|
|
||||||
response += rq.upgraded.fromApp.Load()
|
|
||||||
request += rq.upgraded.toApp.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch rq.h.config.BytesCount {
|
switch rq.h.config.BytesCount {
|
||||||
case "response":
|
case "response":
|
||||||
@@ -151,7 +84,7 @@ func (rq *request) countedBytes() int64 {
|
|||||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||||
// one hit names, notes the offence for the log line and counts the hit in
|
// one hit names, notes the offence for the log line and counts the hit in
|
||||||
// the metrics. status is what the client was sent, or is sent:
|
// the metrics. status is what the client was sent, or is sent:
|
||||||
// SWWAF_BAN_RESPONSE for a request over a rate limit, the app's answer for
|
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||||
// error burst. The ban's notes give the client's limit percentage for a
|
// error burst. The ban's notes give the client's limit percentage for a
|
||||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||||
@@ -290,7 +223,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
|||||||
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||||
// made at now would be sent. In observe mode the ban the request would
|
// made at now would be sent. In observe mode the ban the request would
|
||||||
// have made is worked out only then, at most once per
|
// have made is worked out only then, at most once per
|
||||||
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||||
// netblock's requests, which can mean going through every client.
|
// netblock's requests, which can mean going through every client.
|
||||||
func (rq *request) wouldAlertBan(
|
func (rq *request) wouldAlertBan(
|
||||||
netblock netip.Prefix, now time.Time, cause string,
|
netblock netip.Prefix, now time.Time, cause string,
|
||||||
@@ -323,6 +256,7 @@ func (rq *request) alertBan(ban bans.Ban) {
|
|||||||
|
|
||||||
rq.h.alerts.Raise(alerts.Alert{
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
Event: event,
|
Event: event,
|
||||||
|
Host: rq.site,
|
||||||
Client: rq.client,
|
Client: rq.client,
|
||||||
Netblock: ban.Netblock,
|
Netblock: ban.Netblock,
|
||||||
ASN: ban.Notes.ASN,
|
ASN: ban.Notes.ASN,
|
||||||
@@ -339,11 +273,11 @@ func (rq *request) alertBan(ban bans.Ban) {
|
|||||||
func (rq *request) noted(now time.Time, status int) bans.Request {
|
func (rq *request) noted(now time.Time, status int) bans.Request {
|
||||||
return bans.Request{
|
return bans.Request{
|
||||||
Time: now,
|
Time: now,
|
||||||
Method: rq.in.Method,
|
Method: rq.line.Method,
|
||||||
Host: rq.in.Host,
|
Host: rq.line.Host,
|
||||||
Path: rq.in.URL.RequestURI(),
|
Path: rq.requestURI,
|
||||||
Status: status,
|
Status: status,
|
||||||
UserAgent: rq.in.UserAgent(),
|
UserAgent: rq.line.UserAgent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +289,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// netblock is the netblock a ban on client covers: its IPv4 address,
|
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||||
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||||
// counts it in.
|
// counts it in.
|
||||||
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||||
addr := client.Unmap()
|
addr := client.Unmap()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -9,12 +9,13 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ const (
|
|||||||
userAgent = "ban-test/1.0"
|
userAgent = "ban-test/1.0"
|
||||||
// permanent is the log line's ban_expires for a permanent ban.
|
// permanent is the log line's ban_expires for a permanent ban.
|
||||||
permanent = "permanent"
|
permanent = "permanent"
|
||||||
|
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
|
||||||
|
banResponseClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBrokenLimitBansTheClient(t *testing.T) {
|
func TestBrokenLimitBansTheClient(t *testing.T) {
|
||||||
@@ -86,19 +89,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within
|
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
|
||||||
// SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10*time.Minute + time.Hour)
|
clk.advance(10*time.Minute + time.Hour)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// Later than that, SWWAF_LIMIT_BAN_DURATION again.
|
// Later than that, SWW_LIMIT_BAN_DURATION again.
|
||||||
clk.advance(30*time.Minute + time.Hour + time.Second)
|
clk.advance(30*time.Minute + time.Hour + time.Second)
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10 * time.Minute)
|
clk.advance(10 * time.Minute)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// 90 minutes would be longer than SWWAF_MAX_BAN_DURATION: the ban is
|
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
|
||||||
// permanent.
|
// permanent.
|
||||||
clk.advance(30 * time.Minute)
|
clk.advance(30 * time.Minute)
|
||||||
|
|
||||||
@@ -145,8 +148,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
|
|
||||||
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
||||||
const (
|
const (
|
||||||
allowed = "203.0.113.60" // in SWWAF_ALLOW_NETS
|
allowed = "203.0.113.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -161,7 +164,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
nil, []string{otherClient, exempt},
|
nil, []string{otherClient, exempt},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets",
|
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
|
||||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||||
},
|
},
|
||||||
@@ -170,7 +173,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets",
|
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
|
||||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
||||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
||||||
},
|
},
|
||||||
@@ -208,7 +211,7 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
banScopeV4Prefix: "24",
|
banScopeV4Prefix: "24",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||||
@@ -225,19 +228,35 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
|
rateLimitPerMinute: "1", banScopeV4Prefix: "24",
|
||||||
|
})
|
||||||
|
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
||||||
|
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
|
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
wantHeuristic(t, line, "active_ban",
|
||||||
|
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWWAF_BAN_RESPONSE at its default
|
setting string // "" leaves SWW_BAN_RESPONSE at its default
|
||||||
status int // 0 is the connection closed without an answer
|
status int // 0 is the connection closed without an answer
|
||||||
}{
|
}{
|
||||||
{"", http.StatusForbidden},
|
{"", http.StatusForbidden},
|
||||||
{"403", http.StatusForbidden},
|
{"403", http.StatusForbidden},
|
||||||
{"429", http.StatusTooManyRequests},
|
{"429", http.StatusTooManyRequests},
|
||||||
{"close", 0},
|
{banResponseClose, 0},
|
||||||
} {
|
} {
|
||||||
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
|
t.Run(banResponse+"="+tc.setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -247,7 +266,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -257,7 +276,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(t, geojsURL, env)
|
s, _, _ := startWithClock(t, geojsURL, env)
|
||||||
|
|
||||||
s.get(denied, tc.status, requestlog.ActionDenied)
|
s.get(denied, tc.status, requestlog.ActionDenied)
|
||||||
s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
|
s.get(fromKP, tc.status, requestlog.ActionDenied)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
||||||
@@ -272,7 +291,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -349,7 +368,7 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
|||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clock is the time a test sets, by which smallwebwaf counts requests and
|
// clock is the time a test sets, by which sww counts requests and
|
||||||
// makes bans.
|
// makes bans.
|
||||||
type clock struct {
|
type clock struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -372,14 +391,14 @@ func (c *clock) advance(d time.Duration) {
|
|||||||
c.now = c.now.Add(d)
|
c.now = c.now.Add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startWithClock starts smallwebwaf in front of an app that answers 200,
|
// startWithClock starts discriminator, and rproxy in front of an app that answers 200,
|
||||||
// with the settings in env on top of trusting localhost's
|
// with the settings in env on top of trusting localhost's
|
||||||
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
||||||
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
||||||
// window.
|
// window.
|
||||||
func startWithClock(
|
func startWithClock(
|
||||||
t *testing.T, geojsURL string, env map[string]string,
|
t *testing.T, geojsURL string, env map[string]string,
|
||||||
) (*sender, *clock, *proxy.Server) {
|
) (*sender, *clock, *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
@@ -389,16 +408,18 @@ func startWithClock(
|
|||||||
|
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||||
|
|
||||||
return &sender{t: t, addr: addr, out: out}, clk, server
|
return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server
|
||||||
}
|
}
|
||||||
|
|
||||||
// sender sends requests to smallwebwaf one after another, each on a
|
// sender sends requests to rproxy one after another, each on a connection of its
|
||||||
// connection of its own, and checks each one's answer and log line. They
|
// own, and checks each one's answer and log line. discriminatorAddr is where
|
||||||
// must be the only requests smallwebwaf is sent, since the log lines are
|
// discriminator's own endpoints are, which adminRequest asks. Every request
|
||||||
|
// discriminator gets must come through the sender, since the log lines are
|
||||||
// matched to them in order.
|
// matched to them in order.
|
||||||
type sender struct {
|
type sender struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
addr string
|
addr string
|
||||||
|
discriminatorAddr string
|
||||||
out *output
|
out *output
|
||||||
sent int
|
sent int
|
||||||
}
|
}
|
||||||
@@ -417,22 +438,19 @@ func (s *sender) get(from string, status int, action string) logLine {
|
|||||||
func (s *sender) request(from, path string, status int, action string) logLine {
|
func (s *sender) request(from, path string, status int, action string) logLine {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
line, _ := s.requestWithHeader(from, path, "", status, action)
|
return s.requestWithHeader(from, path, "", status, action)
|
||||||
|
|
||||||
return line
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestWithHeader is request with header, such as "Authorization:
|
// requestWithHeader is request with header, such as "Authorization:
|
||||||
// Bearer x", added to the request unless it is "". It returns the body of
|
// Bearer x", added to the request unless it is "".
|
||||||
// the answer too.
|
|
||||||
func (s *sender) requestWithHeader(
|
func (s *sender) requestWithHeader(
|
||||||
from, path, header string, status int, action string,
|
from, path, header string, status int, action string,
|
||||||
) (logLine, string) {
|
) logLine {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
line, got := s.requestWithBody(http.MethodGet, from, path, header, "", status, action)
|
line, _ := s.requestWithBody(http.MethodGet, from, path, header, "", status, action)
|
||||||
|
|
||||||
return line, string(got.body)
|
return line
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestWithBody is requestWithHeader for a request with method, whose
|
// requestWithBody is requestWithHeader for a request with method, whose
|
||||||
@@ -448,10 +466,14 @@ func (s *sender) requestWithBody(
|
|||||||
header += "\r\n"
|
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)
|
conn := dial(s.t, s.addr)
|
||||||
send(s.t, conn, method+" "+path+" HTTP/1.1\r\nHost: "+appHost+
|
send(s.t, conn, method+" "+path+" HTTP/1.1\r\nHost: "+appHost+"\r\n"+
|
||||||
"\r\nUser-Agent: "+userAgent+"\r\n"+forwardedFor+": "+from+"\r\n"+
|
forwardedFor+": "+from+"\r\n"+header+"\r\n"+body)
|
||||||
header+"\r\n"+body)
|
|
||||||
|
|
||||||
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -476,7 +498,7 @@ func (s *sender) requestWithBody(
|
|||||||
got.status, status)
|
got.status, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
line := s.out.requestLines(s.t, s.sent+1)[s.sent]
|
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||||
s.sent++
|
s.sent++
|
||||||
wantLine(s.t, line, status, action)
|
wantLine(s.t, line, status, action)
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
@@ -17,28 +17,29 @@ type percentage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
// client's limits: one of its lists is not empty,
|
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
|
||||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_ASN_LIMIT_PERCENT_URL
|
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
|
||||||
// is set. The client's lookup is then needed before its request goes on.
|
// 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 {
|
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||||
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||||
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
|
||||||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// limitPercentages returns the client's limit percentages, for the rate
|
// limitPercentages returns the client's limit percentages, for the rate
|
||||||
// limits and for the byte limits, by its AS number and country as looked
|
// limits and for the byte limits, by its tags, its AS number and country
|
||||||
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
|
||||||
// that list it. Each is the lowest of those the settings give it, the
|
// AbuseIPDB that list it. Each is the lowest of those the settings give
|
||||||
// first of them in the order below when several are lowest: the
|
// it, the first of them in the order below when several are lowest: the
|
||||||
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
|
||||||
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
||||||
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
|
||||||
// country, SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists,
|
// other tags, for a client without a country, unless its request skipped
|
||||||
// the percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a
|
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
||||||
// client a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit,
|
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
|
||||||
// the percentage of SWWAF_REPUTATION_ACTION while it is limit. For the
|
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
||||||
// byte limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT
|
// 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
|
// take the place of the first three for an AS number or a country they
|
||||||
// list.
|
// list.
|
||||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
@@ -46,45 +47,54 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
asn, country := rq.line.ASN, rq.line.Country
|
asn, country := rq.line.ASN, rq.line.Country
|
||||||
|
|
||||||
unknown := percentage{percent: whole}
|
unknown := percentage{percent: whole}
|
||||||
if country == "" {
|
if country == "" && !rq.skippedLookupWait {
|
||||||
unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"}
|
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetched := percentage{percent: whole}
|
fetched := percentage{percent: whole}
|
||||||
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||||
fetched = percentage{percent, "SWWAF_ASN_LIMIT_PERCENT_URL"}
|
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
|
||||||
}
|
}
|
||||||
|
|
||||||
blocklisted := percentage{percent: whole}
|
blocklisted := percentage{percent: whole}
|
||||||
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||||
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
|
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
reputationListed := percentage{percent: whole}
|
reputationListed := percentage{percent: whole}
|
||||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
|
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
|
||||||
fetched)
|
|
||||||
countryRequests := given(cfg.CountryLimitPercent, country,
|
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
|
||||||
"SWWAF_COUNTRY_LIMIT_PERCENT")
|
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
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||||
asnBytes = given(cfg.ASNBytesPercent, asn, "SWWAF_ASN_BYTES_PERCENT")
|
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
|
||||||
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
|
reputationListed),
|
||||||
|
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// given returns the percentage percents, the setting named setting, gives
|
// given returns the percentage percents, the setting named setting, gives
|
||||||
// code, an AS number or a country, or whole when it does not list code.
|
// 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 {
|
func given(percents map[string]int64, code, setting string) percentage {
|
||||||
percent, listed := percents[code]
|
percent, listed := percents[code]
|
||||||
if !listed {
|
if !listed {
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -16,18 +15,17 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The biased thresholds.
|
// The biased thresholds.
|
||||||
const (
|
const (
|
||||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
||||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -36,6 +34,11 @@ const (
|
|||||||
asnDEHalf = asnDE + ":50"
|
asnDEHalf = asnDE + ":50"
|
||||||
asnDEQuarter = asnDE + ":25"
|
asnDEQuarter = asnDE + ":25"
|
||||||
countryDEHalf = "de:50"
|
countryDEHalf = "de:50"
|
||||||
|
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
|
||||||
|
asnTagDEHalf = "asn=64496:50"
|
||||||
|
asnTagDEQuarter = "asn=64496:25"
|
||||||
|
countryTagDEHalf = "country=DE:50"
|
||||||
|
countryTagDEQuarter = "country=DE:25"
|
||||||
// noCountry is in an AS of its own, AS64500, and in no country.
|
// noCountry is in an AS of its own, AS64500, and in no country.
|
||||||
noCountry = "192.0.2.80"
|
noCountry = "192.0.2.80"
|
||||||
// fourAMinute is the rate limit these tests set: half of it is 2
|
// fourAMinute is the rate limit these tests set: half of it is 2
|
||||||
@@ -55,8 +58,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{asnLimitPercent, asnDEHalf, fromDE},
|
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||||
{countryLimitPercent, countryDEHalf, fromDE},
|
{tagLimitPercent, countryTagDEHalf, fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
@@ -97,8 +100,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{asnLimitPercent, asnDEHalf, fromDE},
|
{tagLimitPercent, asnTagDEHalf, fromDE},
|
||||||
{countryLimitPercent, "DE:50", fromDE},
|
{tagLimitPercent, "Country=de:50", fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
{asnBytesPercent, "as64496:50", fromDE},
|
{asnBytesPercent, "as64496:50", fromDE},
|
||||||
{countryBytesPercent, countryDEHalf, fromDE},
|
{countryBytesPercent, countryDEHalf, fromDE},
|
||||||
@@ -152,13 +155,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back",
|
"raising the byte limits back",
|
||||||
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
|
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||||
"50 from " + asnLimitPercent, none, "",
|
"50 from " + tagLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back, by country",
|
"raising the byte limits back, by country",
|
||||||
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
|
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
||||||
"50 from " + countryLimitPercent, none, "",
|
"50 from " + tagLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -185,19 +188,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
|
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
|
||||||
|
|
||||||
// The first request breaks the limit, and bans the client; the log line
|
// The first request breaks the limit, and bans the client; the log line
|
||||||
// gives the 0.
|
// gives the 0.
|
||||||
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
if line.fields["limit_percent"] != float64(0) ||
|
if line.fields["limit_percent"] != float64(0) ||
|
||||||
line.fields["limit_percent_setting"] != asnLimitPercent {
|
line.fields["limit_percent_setting"] != unknownLimitPercent {
|
||||||
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
||||||
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
||||||
asnLimitPercent)
|
unknownLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLowestPercentageApplies(t *testing.T) {
|
func TestLowestPercentageApplies(t *testing.T) {
|
||||||
@@ -212,29 +215,29 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"the country's",
|
"the country's",
|
||||||
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
|
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
|
||||||
fromDE, "25 from " + countryLimitPercent,
|
fromDE, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's",
|
"the AS number's",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
|
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
|
||||||
fromDE, "25 from " + asnLimitPercent,
|
fromDE, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's, the first of two alike",
|
"the AS number's, the first of two alike",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
|
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
|
||||||
fromDE, "25 from " + asnLimitPercent,
|
fromDE, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"that for a client without a country",
|
"that for a client without a country",
|
||||||
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
|
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
||||||
noCountry, "25 from " + unknownLimitPercent,
|
noCountry, "25 from " + unknownLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||||
"the AS number's, for a client without a country",
|
"the AS number's, for a client without a country",
|
||||||
map[string]string{asnLimitPercent: "AS64500:25"},
|
map[string]string{tagLimitPercent: "asn=64500:25"},
|
||||||
noCountry, "25 from " + asnLimitPercent,
|
noCountry, "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -281,12 +284,12 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
||||||
time.Now, map[string]string{unknownLimitPercent: "0"})
|
map[string]string{unknownLimitPercent: "0"})
|
||||||
|
|
||||||
// Once the second the request waits for its answer is up, the client
|
// Once the time the request waits for its answer is up, the client
|
||||||
// counts as without a country, and its zero allowance refuses the
|
// counts as without a country, and its zero allowance refuses the
|
||||||
// request before it reaches the app.
|
// request.
|
||||||
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
@@ -305,8 +308,12 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
setting, value string
|
setting, value string
|
||||||
waits bool
|
waits bool
|
||||||
}{
|
}{
|
||||||
{asnLimitPercent, asnDEHalf, true},
|
{tagLimitPercent, asnTagDEHalf, true},
|
||||||
{countryLimitPercent, countryDEHalf, true},
|
{tagLimitPercent, countryTagDEHalf, true},
|
||||||
|
{tagDeny, denyKP, true},
|
||||||
|
// A tag the lookup does not give needs no answer.
|
||||||
|
{tagLimitPercent, "vpn:50", false},
|
||||||
|
{tagDeny, "vpn", false},
|
||||||
{asnBytesPercent, asnDEHalf, true},
|
{asnBytesPercent, asnDEHalf, true},
|
||||||
{countryBytesPercent, countryDEHalf, true},
|
{countryBytesPercent, countryDEHalf, true},
|
||||||
{unknownLimitPercent, "99", true},
|
{unknownLimitPercent, "99", true},
|
||||||
@@ -320,12 +327,9 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
// The request's body is over SWWAF_REQUEST_MAX_BYTES, so that it
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
||||||
// is refused after the checks, and never reaches the app.
|
map[string]string{
|
||||||
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
||||||
time.Now, map[string]string{
|
|
||||||
lookupTimeout: timeout.String(), requestMaxBytes: "1",
|
|
||||||
tc.setting: tc.value,
|
|
||||||
})
|
})
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|
||||||
@@ -340,8 +344,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
t.Errorf("the request waited %s for its answer, want %s", waited, want)
|
t.Errorf("the request waited %s for its answer, want %s", waited, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusRequestEntityTooLarge,
|
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
||||||
requestlog.ActionTooLarge)
|
|
||||||
|
|
||||||
// The bubble's clock stops once this function returns, so the
|
// The bubble's clock stops once this function returns, so the
|
||||||
// request to GeoJS, which a request that did not wait leaves
|
// request to GeoJS, which a request that did not wait leaves
|
||||||
@@ -370,15 +373,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
{
|
{
|
||||||
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
||||||
"a rate limit",
|
"a rate limit",
|
||||||
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
|
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
|
||||||
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
||||||
"requests per minute over the limit of 3", "25 from " + asnLimitPercent,
|
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The byte limits' percentage, not the rate limits'.
|
// The byte limits' percentage, not the rate limits'.
|
||||||
"a byte limit",
|
"a byte limit",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
|
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
||||||
asnBytesPercent: asnDEHalf,
|
asnBytesPercent: asnDEHalf,
|
||||||
},
|
},
|
||||||
0, http.StatusOK, requestlog.ActionForward,
|
0, http.StatusOK, requestlog.ActionForward,
|
||||||
@@ -426,7 +429,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
// clock.
|
// clock.
|
||||||
func startWithLookups(
|
func startWithLookups(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *proxy.Server, *alerts.Queue) {
|
) (*sender, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
s, _, server, queue := startWithLookupsAndClock(t, env)
|
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||||
@@ -441,7 +444,7 @@ func startWithLookups(
|
|||||||
// country, and no other address.
|
// country, and no other address.
|
||||||
func startWithLookupsAndClock(
|
func startWithLookupsAndClock(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *clock, *proxy.Server, *alerts.Queue) {
|
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
@@ -467,13 +470,17 @@ func (s *sender) uploadFrom(from string) logLine {
|
|||||||
return line
|
return line
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveFromDE hands a request from fromDE with method and body straight to
|
// serveFromDE hands a question about a request from fromDE with method and body
|
||||||
// server's handler, without the network, and returns once it is answered.
|
// straight to server's handler, without the network, and returns once it is
|
||||||
func serveFromDE(t *testing.T, server *proxy.Server, method string, body io.Reader) {
|
// answered.
|
||||||
|
func serveFromDE(
|
||||||
|
t *testing.T, server *discriminator.Server, method string, body io.Reader,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||||
req.RemoteAddr = net.JoinHostPort(fromDE, "1234")
|
req.Header.Set(discriminator.ClientHeader, fromDE)
|
||||||
|
req.Header.Set(requestIDHeader, "the-id")
|
||||||
|
|
||||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
}
|
}
|
||||||
@@ -490,7 +497,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// percentText gives a limit percentage and the setting that gave it as
|
// percentText gives a limit percentage and the setting that gave it as
|
||||||
// text, such as "50 from SWWAF_ASN_LIMIT_PERCENT", or none when both are
|
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
|
||||||
// left out.
|
// left out.
|
||||||
func percentText(percent *int64, setting string) string {
|
func percentText(percent *int64, setting string) string {
|
||||||
switch {
|
switch {
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
|
||||||
|
// through the reputation lists.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blocklists notes the blocklists that list the client, as noteListed does,
|
||||||
|
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it
|
||||||
|
// lowers the client's limits instead (see limitPercentages), and being log, it
|
||||||
|
// does nothing more. Its figure is the blocklists that list the client.
|
||||||
|
type blocklists struct{}
|
||||||
|
|
||||||
|
func (blocklists) name() string { return "blocklists" }
|
||||||
|
|
||||||
|
func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
listedBy := rq.h.lists.ListedBy(rq.client)
|
||||||
|
rq.blocklisted = len(listedBy) > 0
|
||||||
|
rq.noteListed(listedBy, "listed by a blocklist")
|
||||||
|
|
||||||
|
if !rq.refusedByBlocklists(listedBy) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionDenied,
|
||||||
|
figures: map[string]any{"lists": listedBy},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refusedByBlocklists reports whether the heuristic blocklists refuses a
|
||||||
|
// client, given listedBy, the blocklists that list it: it does when one lists
|
||||||
|
// it and SWW_BLOCKLIST_ACTION is deny.
|
||||||
|
func (rq *request) refusedByBlocklists(listedBy []string) bool {
|
||||||
|
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.bytesPercent.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{action: requestlog.ActionBanned, figures: limitFigures(hit)}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -19,13 +19,13 @@ import (
|
|||||||
|
|
||||||
// The byte limit settings.
|
// The byte limit settings.
|
||||||
const (
|
const (
|
||||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWWAF_BYTES_COUNT"
|
bytesCount = "SWW_BYTES_COUNT"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of SWWAF_BYTES_COUNT.
|
// The values of SWW_BYTES_COUNT.
|
||||||
const (
|
const (
|
||||||
countResponse = "response"
|
countResponse = "response"
|
||||||
countRequest = "request"
|
countRequest = "request"
|
||||||
@@ -40,15 +40,13 @@ const (
|
|||||||
// byteLimit is the byte limit these tests set, as a setting: a request
|
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||||
// with a body and its answer, 100 bytes, go over it.
|
// with a body and its answer, 100 bytes, go over it.
|
||||||
byteLimit = "99"
|
byteLimit = "99"
|
||||||
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE.
|
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
|
||||||
minuteBytes = "minute_bytes"
|
minuteBytes = "minute_bytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const scraper = "192.0.2.200"
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, window string
|
setting, window string
|
||||||
// apart is the time between the two requests, which the window
|
// apart is the time between the two requests, which the window
|
||||||
@@ -94,12 +92,27 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
|||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
wantMetric(t, s.scrape(scraper), `smallwebwaf_rate_limit_hits_total{`+
|
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
|
||||||
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
|
||||||
|
|
||||||
|
line, _ := s.download()
|
||||||
|
wantAfterAnswerHeuristic(t, line, "", "null")
|
||||||
|
|
||||||
|
// Two answers of 70 bytes are over the limit of 99.
|
||||||
|
line, _ = s.download()
|
||||||
|
wantHeuristic(t, line, "", "null")
|
||||||
|
wantAfterAnswerHeuristic(t, line, "byte_limits",
|
||||||
|
`{"window": "minute", "count": 140, "limit": 99}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -126,7 +139,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
|||||||
// The 70 bytes passed on before the app broke off are over the limit of
|
// The 70 bytes passed on before the app broke off are over the limit of
|
||||||
// 50, and ban the client for an hour.
|
// 50, and ban the client for an hour.
|
||||||
line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
|
line, got := s.requestWithBody(http.MethodGet, client, "/", "", "", http.StatusOK,
|
||||||
requestlog.ActionUpstreamError)
|
requestlog.ActionForward)
|
||||||
if len(got.body) != answerBytes || line.LimitHit != minuteBytes ||
|
if len(got.body) != answerBytes || line.LimitHit != minuteBytes ||
|
||||||
line.BanExpires != expires {
|
line.BanExpires != expires {
|
||||||
t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+
|
t.Errorf("got %d bytes, and the log line has limit_hit %q and ban_expires %q, "+
|
||||||
@@ -168,39 +181,6 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebSocketPassesTheAnswerAfterTheClientStopsSending(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, echoOnceTheClientStops)
|
|
||||||
addr, out := startProxy(t, app.URL,
|
|
||||||
map[string]string{trustedProxies: trustLocalhost})
|
|
||||||
s := &sender{t: t, addr: addr, out: out}
|
|
||||||
|
|
||||||
conn, reader := s.openWebSocket()
|
|
||||||
send(t, conn, uploadBody)
|
|
||||||
|
|
||||||
// The client closes its sending side and waits for the answer, which the
|
|
||||||
// app sends only once it has seen the client stop. smallwebwaf passes the
|
|
||||||
// close on to the app through CloseWrite on upgradedConn; without that,
|
|
||||||
// it closes both connections, and the answer is lost.
|
|
||||||
tcp, ok := conn.(*net.TCPConn)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("connection is a %T, want a *net.TCPConn", conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := tcp.CloseWrite()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("close the sending side: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := io.ReadAll(reader)
|
|
||||||
if err != nil || string(got) != uploadBody {
|
|
||||||
t.Errorf("got %q (%v), want %q", got, err, uploadBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.closeWebSocket(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -247,8 +227,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
@@ -465,28 +445,6 @@ func answerAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// echoOnceTheClientStops is an app that switches protocols, as for a
|
|
||||||
// WebSocket, reads what the client sends until the client stops sending,
|
|
||||||
// and then sends it all back.
|
|
||||||
func echoOnceTheClientStops(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()
|
|
||||||
|
|
||||||
received, _ := io.ReadAll(buffered)
|
|
||||||
_, _ = buffered.Write(received)
|
|
||||||
_ = buffered.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
// webSocket opens a WebSocket from client to answerAfterUpgrade, sends a
|
// webSocket opens a WebSocket from client to answerAfterUpgrade, sends a
|
||||||
// line of bodyBytes on it, reads the answer, and closes it. It checks the
|
// 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.
|
// answer, and the log line as request does, and returns the log line.
|
||||||
@@ -543,7 +501,7 @@ func (s *sender) closeWebSocket(conn net.Conn) logLine {
|
|||||||
|
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
|
|
||||||
line := s.out.requestLines(s.t, s.sent+1)[s.sent]
|
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
||||||
s.sent++
|
s.sent++
|
||||||
wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward)
|
wantLine(s.t, line, http.StatusSwitchingProtocols, requestlog.ActionForward)
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
|
||||||
|
// and SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// coreRuleSet inspects the request with the Core Rule Set, unless
|
||||||
|
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
||||||
|
// 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 waf_block alert, and in block mode refuses the request with 403,
|
||||||
|
// which is an offence its client's history counts. Its figures are the score
|
||||||
|
// and the threshold.
|
||||||
|
type coreRuleSet struct{}
|
||||||
|
|
||||||
|
func (coreRuleSet) name() string { return "core_rule_set" }
|
||||||
|
|
||||||
|
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.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
|
||||||
|
rq.line.WAFRuleIDs = result.RuleIDs
|
||||||
|
rq.line.WAFScore = &result.Score
|
||||||
|
|
||||||
|
for _, id := range result.RuleIDs {
|
||||||
|
rq.h.metrics.WAFMatched(cfg.WAFMode, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
threshold := cfg.WAFAnomalyThreshold
|
||||||
|
if threshold == 0 || result.Score < threshold {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.addTag(config.ExploitSignatureTag)
|
||||||
|
rq.alertWAFBlock(result)
|
||||||
|
|
||||||
|
if cfg.WAFMode == config.WAFModeDetect {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.wafBlocked = true
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionWAFBlocked,
|
||||||
|
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// alertWAFBlock raises the waf_block 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) alertWAFBlock(result waf.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.EventWAFBlock,
|
||||||
|
Host: rq.site,
|
||||||
|
Client: rq.client,
|
||||||
|
Netblock: rq.h.clientGroup(rq.client),
|
||||||
|
ASN: rq.line.ASN,
|
||||||
|
ASName: rq.line.ASName,
|
||||||
|
Country: rq.line.Country,
|
||||||
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -8,20 +8,21 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and
|
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
||||||
// its two modes that inspect requests.
|
// its two modes that inspect requests.
|
||||||
const (
|
const (
|
||||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
|
apps = "SWW_APPS"
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
@@ -29,9 +30,16 @@ const (
|
|||||||
// formData is the type of a form's body.
|
// formData is the type of a form's body.
|
||||||
const formData = "application/x-www-form-urlencoded"
|
const formData = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
|
// sizeLimit is 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
|
// sqlInjection asks for / with an SQL injection in its query, which only
|
||||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD.
|
// SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||||
|
|
||||||
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
||||||
@@ -55,6 +63,41 @@ func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
mode string
|
||||||
|
status int
|
||||||
|
action string
|
||||||
|
}{
|
||||||
|
{block, block, http.StatusForbidden, requestlog.ActionWAFBlocked},
|
||||||
|
{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.ActionDenied)
|
||||||
|
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -96,7 +139,7 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
|
|||||||
} {
|
} {
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
||||||
|
|
||||||
line, _ := s.requestWithHeader(client, attack.path, attack.header,
|
line := s.requestWithHeader(client, attack.path, attack.header,
|
||||||
tc.status, tc.action)
|
tc.status, tc.action)
|
||||||
wantWAF(t, line, &attack.score, attack.ruleIDs...)
|
wantWAF(t, line, &attack.score, attack.ruleIDs...)
|
||||||
}
|
}
|
||||||
@@ -104,6 +147,16 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
||||||
|
|
||||||
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
||||||
|
requestlog.ActionWAFBlocked)
|
||||||
|
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -119,7 +172,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block,
|
wafMode: block,
|
||||||
@@ -128,7 +181,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS
|
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
|
||||||
// exempts, are not inspected.
|
// exempts, are not inspected.
|
||||||
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
@@ -147,7 +200,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionRuleBlocked)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
|
|
||||||
// With SWWAF_WAF_MODE off, no request is.
|
// With SWW_WAF_MODE off, no request is.
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
||||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
@@ -231,6 +284,27 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Only gitea's fixes let a file name through in path, a parameter
|
||||||
|
// gitea sends file names in.
|
||||||
|
const fileName = "/?path=.gitignore"
|
||||||
|
|
||||||
|
s := startDiscriminator(t, "", map[string]string{
|
||||||
|
wafMode: block, apps: siteA + ":gitea",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
||||||
|
http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
|
||||||
|
http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
|
|
||||||
|
lines := s.out.requestLines(t, 2)
|
||||||
|
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionWAFBlocked)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -299,7 +373,7 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
|||||||
tc.name, len(got.body), got.status, len(tc.body))
|
tc.name, len(got.body), got.status, len(tc.body))
|
||||||
}
|
}
|
||||||
|
|
||||||
line := out.requestLines(t, i+1)[i]
|
line := out.endedLines(t, i+1)[i]
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
if line.RequestBytes != int64(len(tc.body)) {
|
if line.RequestBytes != int64(len(tc.body)) {
|
||||||
@@ -309,119 +383,6 @@ func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormBodyLongerThanTheLimitStreamsOnToTheApp(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
first = "a=" // and twice the limit of b's, then the rest
|
|
||||||
rest = 64 * sizeLimit
|
|
||||||
)
|
|
||||||
|
|
||||||
// past is closed once the app has received twice what the Core Rule
|
|
||||||
// Set reads, and got is the length of the whole body it received.
|
|
||||||
past := make(chan struct{})
|
|
||||||
got := make(chan int64, 1)
|
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
n, _ := io.CopyN(io.Discard, r.Body, 2*sizeLimit)
|
|
||||||
|
|
||||||
close(past)
|
|
||||||
|
|
||||||
m, _ := io.Copy(io.Discard, r.Body)
|
|
||||||
got <- n + m
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
|
||||||
})
|
|
||||||
|
|
||||||
// The client sends the rest only once the app has received the first
|
|
||||||
// part: were smallwebwaf to hold the body until the end, it would
|
|
||||||
// never come.
|
|
||||||
body, sender := io.Pipe()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_, _ = io.WriteString(sender, first+strings.Repeat("b", 2*sizeLimit))
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-past:
|
|
||||||
case <-time.After(waitLimit):
|
|
||||||
t.Error("the app got no more than the Core Rule Set reads " +
|
|
||||||
"before the whole body was sent")
|
|
||||||
|
|
||||||
_ = sender.CloseWithError(io.ErrUnexpectedEOF)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = io.WriteString(sender, strings.Repeat("b", rest))
|
|
||||||
_ = sender.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
|
||||||
req.Header.Set("Content-Type", formData)
|
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
|
||||||
|
|
||||||
want := int64(len(first) + 2*sizeLimit + rest)
|
|
||||||
if n := <-got; n != want {
|
|
||||||
t.Errorf("the app got %d bytes, want %d", n, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientTooSlowToSendWhatTheCoreRuleSetReads(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
wafMode: block, wafBodyLimit: sizeLimitSetting,
|
|
||||||
clientRequestTimeout: shortTimeoutSetting, metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "POST /comment HTTP/1.1\r\nHost: app\r\nContent-Type: "+formData+
|
|
||||||
"\r\nContent-Length: 100\r\n\r\ncontent=the first bytes")
|
|
||||||
|
|
||||||
wantStatus(t, readResponse(t, conn), http.StatusRequestTimeout)
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantLine(t, line, http.StatusRequestTimeout, requestlog.ActionTimedOut)
|
|
||||||
wantNotSentToTheApp(t, line)
|
|
||||||
wantLimitHits(t, addr, clientRequestTimeout, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBodyOverTheSizeLimitWhileTheCoreRuleSetReadsIt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
wafMode: block, wafBodyLimit: "4K",
|
|
||||||
requestMaxBytes: sizeLimitSetting, metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sent in chunks, its length is not announced, and is found to be over
|
|
||||||
// the limit as the Core Rule Set reads it.
|
|
||||||
body := io.MultiReader(strings.NewReader("a=" + strings.Repeat("b", 2*sizeLimit)))
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
|
||||||
req.Header.Set("Content-Type", formData)
|
|
||||||
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantLine(t, line, http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
|
|
||||||
wantNotSentToTheApp(t, line)
|
|
||||||
wantLimitHits(t, addr, requestMaxBytes, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantNotSentToTheApp checks that the request of line was not sent to the
|
|
||||||
// app at all.
|
|
||||||
func wantNotSentToTheApp(t *testing.T, line logLine) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, sent := line.fields["duration_upstream_total"]
|
|
||||||
if sent {
|
|
||||||
t.Error("log line has duration_upstream_total, for a request sent to the app")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponsesAreNotInspected(t *testing.T) {
|
func TestResponsesAreNotInspected(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -441,14 +402,12 @@ func TestResponsesAreNotInspected(t *testing.T) {
|
|||||||
t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body)
|
t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
wantLine(t, out.endedLine(t), http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) {
|
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const scraper = "192.0.2.200"
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
|
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
|
||||||
})
|
})
|
||||||
@@ -474,20 +433,18 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
|||||||
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape(scraper)
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
`sww_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
`sww_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+
|
wantMetric(t, metrics, `sww_requests_total{action="waf_blocked",`+
|
||||||
`instance="app",status_class="4xx"}`, 3)
|
`instance="app",status_class="4xx"}`, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) {
|
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const scraper = "192.0.2.200"
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
|
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
|
||||||
})
|
})
|
||||||
@@ -503,11 +460,11 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
|
|||||||
t.Errorf("history counts the offences %+v, want none", offences)
|
t.Errorf("history counts the offences %+v, want none", offences)
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape(scraper)
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
`sww_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`)
|
`sww_offences_total{instance="app",kind="waf_blocked"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
||||||
@@ -576,9 +533,10 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
|||||||
Instance: alertInstance,
|
Instance: alertInstance,
|
||||||
Time: clk.Now(),
|
Time: clk.Now(),
|
||||||
Event: alerts.EventWAFBlock,
|
Event: alerts.EventWAFBlock,
|
||||||
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(client),
|
Client: netip.MustParseAddr(client),
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||||
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
|
||||||
|
// does not hold the client's country as it was looked up. A client without a
|
||||||
|
// country, or whose country cannot be found, is refused. Its figures are the
|
||||||
|
// country and the setting that refused it.
|
||||||
|
type countries struct{}
|
||||||
|
|
||||||
|
func (countries) name() string { return "countries" }
|
||||||
|
|
||||||
|
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||||
|
country := rq.line.Country
|
||||||
|
|
||||||
|
if len(allowed) == 0 || slices.Contains(allowed, country) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionCountryDenied,
|
||||||
|
figures: map[string]any{
|
||||||
|
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
refused []string
|
refused []string
|
||||||
}{
|
}{
|
||||||
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
|
|
||||||
{
|
{
|
||||||
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"},
|
"with a country in SWW_TAG_DENY",
|
||||||
|
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -65,7 +65,7 @@ func TestCountryLists(t *testing.T) {
|
|||||||
req.Header.Set(forwardedFor, sent.client)
|
req.Header.Set(forwardedFor, sent.client)
|
||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
|
|
||||||
line := out.requestLines(t, i+1)[i]
|
line := out.endedLines(t, i+1)[i]
|
||||||
if line.ASN != sent.asn || line.ASName != sent.asName ||
|
if line.ASN != sent.asn || line.ASName != sent.asName ||
|
||||||
line.Country != sent.country {
|
line.Country != sent.country {
|
||||||
t.Errorf("log line has %q, %q and %q, want %q, %q and %q",
|
t.Errorf("log line has %q, %q and %q, want %q, %q and %q",
|
||||||
@@ -90,7 +90,24 @@ func TestCountryLists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCountryRefusalComesBeforeTheBody(t *testing.T) {
|
func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||||
|
lookupTimeout: "1h", allowedCountries: "de",
|
||||||
|
})
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
|
wantHeuristic(t, line, "countries",
|
||||||
|
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||||
|
|
||||||
|
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
|
wantHeuristic(t, line, "countries",
|
||||||
|
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
@@ -102,20 +119,16 @@ func TestCountryRefusalComesBeforeTheBody(t *testing.T) {
|
|||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
deniedCountries: "kp",
|
allowedCountries: "de",
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||||
req.Header.Set(forwardedFor, fromKP)
|
req.Header.Set(forwardedFor, fromKP)
|
||||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.endedLine(t)
|
||||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionCountryDenied)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionCountryDenied)
|
||||||
|
|
||||||
if line.RequestBytes != 0 {
|
|
||||||
t.Errorf("log line has request_bytes %d, want 0", line.RequestBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if calls.Load() != 0 {
|
if calls.Load() != 0 {
|
||||||
t.Errorf("the app was called %d times, want none", calls.Load())
|
t.Errorf("the app was called %d times, want none", calls.Load())
|
||||||
}
|
}
|
||||||
@@ -189,7 +202,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
}{
|
}{
|
||||||
{"no setting needs the lookup", nil},
|
{"no setting needs the lookup", nil},
|
||||||
{"a country list is set", map[string]string{deniedCountries: "kp"}},
|
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -211,7 +224,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
|||||||
|
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
line := out.requestLines(t, i+1)[i]
|
line := out.endedLines(t, i+1)[i]
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
for _, field := range []string{"asn", "as_name", "country"} {
|
for _, field := range []string{"asn", "as_name", "country"} {
|
||||||
@@ -249,11 +262,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"not in SWWAF_ALLOW_NETS", "",
|
"not in SWW_ALLOW_NETS", "",
|
||||||
http.StatusForbidden, requestlog.ActionCountryDenied,
|
http.StatusForbidden, requestlog.ActionCountryDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"in SWWAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
"in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
||||||
http.StatusOK, requestlog.ActionForward,
|
http.StatusOK, requestlog.ActionForward,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -283,10 +296,10 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
||||||
// each in an AS of its own, and no other address. It returns its URL, and
|
// each in an AS of its own, and no other address. It returns its URL, and
|
||||||
// what returns the addresses it has been asked about. A test that needs
|
// what returns the addresses it has been asked about. A test that needs
|
||||||
// the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an
|
// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
|
||||||
// hour, whether or not a request waits for the answer: on the default
|
// hour, whether or not a request waits for the answer: on the default, a
|
||||||
// second, a hold-up of the test process can abandon the request to the
|
// hold-up of the test process can abandon the request to the stand-in, and
|
||||||
// stand-in, and leave the client unknown.
|
// leave the client unknown.
|
||||||
func startGeoJS(t *testing.T) (string, func() []string) {
|
func startGeoJS(t *testing.T) (string, func() []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -354,7 +367,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
||||||
// smallwebwaf reads.
|
// sww reads.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // GeoJS's own names
|
//nolint:tagliatelle // GeoJS's own names
|
||||||
type geojsAnswer struct {
|
type geojsAnswer struct {
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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")
|
||||||
|
rq.banForCrowdSec(now, decision)
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionBanned,
|
||||||
|
figures: map[string]any{
|
||||||
|
"scenario": decision.Scenario,
|
||||||
|
"expires": requestlog.FormatTime(decision.Expires),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crowdSecDecisionInForce returns the decision of the CrowdSec decision list on
|
||||||
|
// the client that is in force at now, and reports whether there is one, which
|
||||||
|
// the heuristic crowdsec refuses the request for.
|
||||||
|
func (rq *request) crowdSecDecisionInForce(
|
||||||
|
now time.Time,
|
||||||
|
) (reputation.Decision, bool) {
|
||||||
|
return rq.h.lists.CrowdSecDecision(rq.client, now)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
// test puts in the copy of its decision list, at decisionsURL, that it
|
// test puts in the copy of its decision list, at decisionsURL, that it
|
||||||
// needs, as reputation.json would at start.
|
// needs, as reputation.json would at start.
|
||||||
const (
|
const (
|
||||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
lapi = "http://crowdsec.example:8080"
|
lapi = "http://crowdsec.example:8080"
|
||||||
decisionsURL = lapi + "/v1/decisions"
|
decisionsURL = lapi + "/v1/decisions"
|
||||||
bouncerKey = "crowdsec-key-0123456789abcdef"
|
bouncerKey = "crowdsec-key-0123456789abcdef"
|
||||||
@@ -84,17 +84,33 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
|
|
||||||
// The ban and the hit are counted, and the list has the metrics of any
|
// The ban and the hit are counted, and the list has the metrics of any
|
||||||
// list fetched from a URL.
|
// list fetched from a URL.
|
||||||
metrics := s.scrape(unplaced)
|
metrics := s.scrape()
|
||||||
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
|
wantMetric(t, metrics, `sww_bans_made_total{cause="crowdsec",`+
|
||||||
`instance="`+alertInstance+`"}`, 1)
|
`instance="`+alertInstance+`"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
||||||
float64(fetched.Unix()))
|
float64(fetched.Unix()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, clk, server, _ := startWithAlerts(t, map[string]string{
|
||||||
|
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
||||||
|
})
|
||||||
|
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
||||||
|
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
||||||
|
`"value": "`+client+`"}]`)
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
|
||||||
|
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
|
||||||
|
wantTags(t, line, "crowdsec")
|
||||||
|
}
|
||||||
|
|
||||||
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -168,7 +184,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|||||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
||||||
// reputation.json would at start.
|
// reputation.json would at start.
|
||||||
func loadDecisions(
|
func loadDecisions(
|
||||||
t *testing.T, server *proxy.Server, fetched time.Time, answer string,
|
t *testing.T, server *discriminator.Server, fetched time.Time, answer string,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic deny_nets. It reads SWW_DENY_NETS.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of
|
||||||
|
// the setting the client is in.
|
||||||
|
type denyNets struct{}
|
||||||
|
|
||||||
|
func (denyNets) name() string { return "deny_nets" }
|
||||||
|
|
||||||
|
func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
for _, netblock := range rq.h.config.DenyNets {
|
||||||
|
if netblock.Contains(rq.client) {
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionDenied,
|
||||||
|
figures: map[string]any{figureNetblock: netblock.String()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
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"`
|
||||||
|
DurationTotal float64 `json:"duration_total"`
|
||||||
|
DurationUpstreamTotal float64 `json:"duration_upstream_total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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, adminPrefix)
|
||||||
|
|
||||||
|
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.ActionAdmin
|
||||||
|
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", "duration_upstream_total",
|
||||||
|
} {
|
||||||
|
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.DurationTotal < 0 || report.DurationTotal > maxReportedMilliseconds:
|
||||||
|
return fmt.Errorf("%w: duration_total", errReportValue)
|
||||||
|
case report.DurationUpstreamTotal < 0 ||
|
||||||
|
report.DurationUpstreamTotal > maxReportedMilliseconds:
|
||||||
|
return fmt.Errorf("%w: duration_upstream_total", 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.DurationTotal = report.DurationTotal
|
||||||
|
|
||||||
|
if rq.allowed {
|
||||||
|
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
|
||||||
|
|
||||||
|
rq.runBanningHeuristics(ctx, onReport())
|
||||||
|
rq.countAnomalies()
|
||||||
|
rq.addToHistory()
|
||||||
|
|
||||||
|
rq.h.metrics.RequestReported(&rq.line,
|
||||||
|
time.Duration(report.DurationTotal*float64(time.Millisecond)),
|
||||||
|
time.Duration(report.DurationUpstreamTotal*float64(time.Millisecond)))
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.writeLine()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
|
||||||
|
// verdicts through the DNSBL checker.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
||||||
|
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
|
||||||
|
// the request when SWW_REPUTATION_ACTION is deny. Being
|
||||||
|
// limit, it lowers the client's limits instead (see limitPercentages), and
|
||||||
|
// being log, it does nothing more. A zone without a verdict on the client is
|
||||||
|
// asked about it in the background, and the request does not wait for the
|
||||||
|
// answer. Its figure is the zones that list the client, their keys masked.
|
||||||
|
type dnsbl struct{}
|
||||||
|
|
||||||
|
func (dnsbl) name() string { return "dnsbl" }
|
||||||
|
|
||||||
|
func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
|
||||||
|
rq.dnsblListed = len(listedBy) > 0
|
||||||
|
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
||||||
|
|
||||||
|
for _, zone := range listedBy {
|
||||||
|
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionDenied,
|
||||||
|
figures: map[string]any{"zones": listedBy},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic header_limits. It reads no setting: its limits are fixed.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The header limits: the most cookies and header fields a request may have
|
||||||
|
// when the checks reach the Core Rule Set. Real clients never reach them.
|
||||||
|
const (
|
||||||
|
maxCookies = 50
|
||||||
|
maxHeaderFields = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// headerLimits refuses a request over the header limits, with 431. Its figures
|
||||||
|
// are the request's header fields and cookies, and the limit of each.
|
||||||
|
type headerLimits struct{}
|
||||||
|
|
||||||
|
func (headerLimits) name() string { return "header_limits" }
|
||||||
|
|
||||||
|
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
fields, cookies := rq.headerCounts()
|
||||||
|
if !overHeaderLimits(fields, cookies) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionTooLarge,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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.adminRequest("", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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{},
|
||||||
|
denyNets{},
|
||||||
|
activeBan{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterLookup are the heuristics that run once the lookup has been made.
|
||||||
|
// tag_deny runs twice: before the rate limits, on the tags known by then, and
|
||||||
|
// last, on the tag rule_files and core_rule_set can add.
|
||||||
|
func afterLookup() []heuristic {
|
||||||
|
return []heuristic{
|
||||||
|
countries{},
|
||||||
|
blocklists{},
|
||||||
|
crowdSec{},
|
||||||
|
dnsbl{},
|
||||||
|
abuseIPDB{},
|
||||||
|
tagDeny{},
|
||||||
|
rateLimits{},
|
||||||
|
trapPaths{},
|
||||||
|
ruleFiles{},
|
||||||
|
headerLimits{},
|
||||||
|
coreRuleSet{},
|
||||||
|
tagDeny{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterAnswer are the heuristics that run on a request once discriminator has
|
||||||
|
// answered it, before its decision line is written. They ban the client, and do
|
||||||
|
// not change what the request got: their verdict's action is not used.
|
||||||
|
func afterAnswer() []heuristic {
|
||||||
|
return []heuristic{
|
||||||
|
refusalLimit{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onReport are the heuristics that run on an allowed request when rproxy's
|
||||||
|
// report of it comes, before its report line is written. Like afterAnswer, they
|
||||||
|
// only ban.
|
||||||
|
func onReport() []heuristic {
|
||||||
|
return []heuristic{
|
||||||
|
byteLimits{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAfterAnswer runs the heuristics of afterAnswer on the request, at the time
|
||||||
|
// it is called.
|
||||||
|
func (rq *request) runAfterAnswer(ctx context.Context) {
|
||||||
|
rq.runBanningHeuristics(ctx, afterAnswer())
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHeuristics runs list, beforeLookup or afterLookup, on the request at now,
|
||||||
|
// and returns the first verdict, or nil when none gives one. The log line's
|
||||||
|
// heuristic and heuristic_figures name the heuristic that gave it, and give its
|
||||||
|
// figures. ctx is the request's own context.
|
||||||
|
func (rq *request) runHeuristics(
|
||||||
|
ctx context.Context, list []heuristic, now time.Time,
|
||||||
|
) *verdict {
|
||||||
|
name, v := rq.firstVerdict(ctx, list, now)
|
||||||
|
if v != nil {
|
||||||
|
rq.line.Heuristic = name
|
||||||
|
rq.line.HeuristicFigures = v.figures
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBanningHeuristics runs list, afterAnswer or onReport, on the request at
|
||||||
|
// the time it is called. The log line's after_answer_heuristic and
|
||||||
|
// after_answer_heuristic_figures name the heuristic that gave a verdict, and
|
||||||
|
// give its figures, so that heuristic and heuristic_figures still name the one
|
||||||
|
// that refused the request. ctx is the request's own context.
|
||||||
|
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
|
||||||
|
name, v := rq.firstVerdict(ctx, list, rq.h.now())
|
||||||
|
if v != nil {
|
||||||
|
rq.line.AfterAnswerHeuristic = name
|
||||||
|
rq.line.AfterAnswerHeuristicFigures = v.figures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstVerdict runs list on the request at now, in order, until a heuristic
|
||||||
|
// gives a verdict, and returns that heuristic's name and its verdict, or "" and
|
||||||
|
// nil when none gives one. ctx is the request's own context.
|
||||||
|
func (rq *request) firstVerdict(
|
||||||
|
ctx context.Context, list []heuristic, now time.Time,
|
||||||
|
) (string, *verdict) {
|
||||||
|
for _, each := range list {
|
||||||
|
v := each.check(ctx, rq, now)
|
||||||
|
if v != nil {
|
||||||
|
return each.name(), v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
@@ -20,7 +20,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
Requests: 4,
|
Requests: 4,
|
||||||
Forwarded: 2,
|
Forwarded: 2,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
// The app answers with no body, smallwebwaf with its status text.
|
// The app answers with no body, sww with its status text.
|
||||||
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
||||||
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
||||||
Offences: ratelimit.Offences{Limit: 1},
|
Offences: ratelimit.Offences{Limit: 1},
|
||||||
@@ -86,7 +86,7 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
|
|||||||
|
|
||||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||||
wantStatus(t, got, http.StatusOK)
|
wantStatus(t, got, http.StatusOK)
|
||||||
out.requestLine(t)
|
out.endedLine(t)
|
||||||
|
|
||||||
history := historyOf(t, server, localhost)
|
history := historyOf(t, server, localhost)
|
||||||
if history.RequestBytes != 3 || history.ResponseBytes != 5 {
|
if history.RequestBytes != 3 || history.ResponseBytes != 5 {
|
||||||
@@ -95,43 +95,41 @@ func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthEndpointIsNotInTheHistory(t *testing.T) {
|
func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK)
|
s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
|
||||||
out.requestLine(t)
|
s.adminRequest("", http.MethodGet, discriminator.MetricsPath, "",
|
||||||
|
http.StatusUnauthorized)
|
||||||
|
s.scrape()
|
||||||
|
|
||||||
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
||||||
t.Errorf("the table holds %+v, want no client", clients)
|
t.Errorf("the table holds %+v, want no client", clients)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestForSmallwebwafIsRefusedOnlyWithoutTheToken(t *testing.T) {
|
func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now,
|
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||||
map[string]string{metricsToken: token})
|
|
||||||
|
|
||||||
// The metrics and the 404 are neither forwarded nor refused; the 401
|
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
|
||||||
// is refused.
|
out.endedLine(t)
|
||||||
scrape(t, addr)
|
|
||||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
|
||||||
wantStatus(t, get(t, addr, proxy.MetricsPath), http.StatusUnauthorized)
|
|
||||||
out.requestLines(t, 3)
|
|
||||||
|
|
||||||
history := historyOf(t, server, localhost)
|
history := historyOf(t, server, localhost)
|
||||||
if history.Requests != 3 || history.Forwarded != 0 || history.Refused != 1 {
|
if history.Requests != 1 || history.Forwarded != 0 || history.Refused != 0 {
|
||||||
t.Errorf("history counts %d requests, %d forwarded and %d refused, "+
|
t.Errorf("history counts %d requests, %d forwarded and %d refused, "+
|
||||||
"want 3, 0 and 1", history.Requests, history.Forwarded, history.Refused)
|
"want 1, 0 and 0", history.Requests, history.Forwarded, history.Refused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// historyOf returns the history of the client at addr.
|
// historyOf returns the history of the client at addr.
|
||||||
func historyOf(t *testing.T, server *proxy.Server, addr string) ratelimit.History {
|
func historyOf(
|
||||||
|
t *testing.T, server *discriminator.Server, addr string,
|
||||||
|
) ratelimit.History {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
client := netip.MustParsePrefix(addr + "/32")
|
client := netip.MustParsePrefix(addr + "/32")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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,9 +1,7 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -22,7 +20,7 @@ import (
|
|||||||
// and country.
|
// and country.
|
||||||
type asnAndCountry struct{ asn, asName, country string }
|
type asnAndCountry struct{ asn, asName, country string }
|
||||||
|
|
||||||
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
|
||||||
// lookup database.
|
// lookup database.
|
||||||
const fileSource = "file"
|
const fileSource = "file"
|
||||||
|
|
||||||
@@ -98,7 +96,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
|||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
requestFromDE := func() *http.Request {
|
requestFromDE := func() *http.Request {
|
||||||
@@ -142,7 +140,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
|||||||
t.Fatalf("first request: %v", err)
|
t.Fatalf("first request: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantLine(t, out.requestLines(t, 2)[1], http.StatusOK, requestlog.ActionForward)
|
wantLine(t, out.endedLines(t, 2)[1], http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// The third request's count holds all three: the first was counted
|
// 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
|
// when it went on, in the minute under way, and the second's count
|
||||||
@@ -150,7 +148,7 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
|||||||
clk.advance(500 * time.Millisecond)
|
clk.advance(500 * time.Millisecond)
|
||||||
wantStatus(t, do(t, requestFromDE()), http.StatusOK)
|
wantStatus(t, do(t, requestFromDE()), http.StatusOK)
|
||||||
|
|
||||||
if got := out.requestLines(t, 3)[2].Counts.Minute; got != 3 {
|
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)
|
t.Errorf("the third request's count in the minute is %v, want 3", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +162,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
|
|||||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
alertWebhookURL: "https://alerts.example/sww",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
s := &sender{t: t, addr: addr, out: out}
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
@@ -319,7 +317,7 @@ func TestLookupHeadersArePassedToTheAppAndTheClientsOwnRemoved(t *testing.T) {
|
|||||||
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
addr, out, _ := startProxyWithClock(t, app.URL, geojsURL, time.Now, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
addLookupHeaders: "true",
|
addLookupHeaders: on,
|
||||||
})
|
})
|
||||||
s := &sender{t: t, addr: addr, out: out}
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
|
|
||||||
@@ -381,24 +379,21 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
|||||||
// clock of the test's own: the wait lasts exactly as long as it should,
|
// clock of the test's own: the wait lasts exactly as long as it should,
|
||||||
// however slowly the test process runs. Nothing in it may wait on the
|
// however slowly the test process runs. Nothing in it may wait on the
|
||||||
// network, which would keep that clock from moving on: the request is
|
// network, which would keep that clock from moving on: the request is
|
||||||
// handed to the proxy's handler, and GeoJS is one that never answers.
|
// handed to discriminator's handler, and GeoJS is one that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
// Not the default second. The exclusive list needs the answer, and
|
// Not the default. The exclusive list needs the answer, and the app
|
||||||
// the app is never reached.
|
// is never reached.
|
||||||
const timeout = 3 * time.Second
|
const timeout = 3 * time.Second
|
||||||
|
|
||||||
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
||||||
time.Now, map[string]string{
|
map[string]string{
|
||||||
lookupTimeout: timeout.String(),
|
lookupTimeout: timeout.String(),
|
||||||
allowedCountries: "DE",
|
allowedCountries: "DE",
|
||||||
})
|
})
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/",
|
|
||||||
http.NoBody)
|
|
||||||
req.RemoteAddr = net.JoinHostPort(fromDE, "1234")
|
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|
||||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||||
|
|
||||||
if waited := time.Since(began); waited != timeout {
|
if waited := time.Since(began); waited != timeout {
|
||||||
t.Errorf("the request waited %s for its answer, want %s", waited, timeout)
|
t.Errorf("the request waited %s for its answer, want %s", waited, timeout)
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
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.adminRequest(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.adminRequest(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="admin",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.ActionDenied)
|
||||||
|
|
||||||
|
// A first broken limit bans for an hour.
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
|
metrics := s.scrape()
|
||||||
|
wantMetric(t, metrics,
|
||||||
|
`sww_requests_total{action="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.ActionRateLimited)
|
||||||
|
|
||||||
|
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_geojs_unanswered_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.adminRequest(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,33 +1,41 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// observe is the value of SWWAF_MODE for observe mode.
|
// observe is the value of SWW_MODE for observe mode.
|
||||||
const observe = "observe"
|
const observe = "observe"
|
||||||
|
|
||||||
|
func 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.ActionDenied)
|
||||||
|
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
banned = otherClient // under a ban read from bans.json
|
banned = otherClient // under a ban read from bans.json
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWWAF_MODE at its default
|
setting string // "" leaves SWW_MODE at its default
|
||||||
observe bool
|
observe bool
|
||||||
}{
|
}{
|
||||||
{"", false},
|
{"", false},
|
||||||
@@ -42,7 +50,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -63,7 +71,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
for _, sent := range []struct{ from, refusal string }{
|
for _, sent := range []struct{ from, refusal string }{
|
||||||
{denied, requestlog.ActionDenied},
|
{denied, requestlog.ActionDenied},
|
||||||
{banned, requestlog.ActionBanned},
|
{banned, requestlog.ActionBanned},
|
||||||
{fromKP, requestlog.ActionCountryDenied},
|
{fromKP, requestlog.ActionDenied},
|
||||||
{fromDE, requestlog.ActionRateLimited},
|
{fromDE, requestlog.ActionRateLimited},
|
||||||
} {
|
} {
|
||||||
if !tc.observe {
|
if !tc.observe {
|
||||||
@@ -76,11 +84,6 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
// Passed to the app, which answered it.
|
// Passed to the app, which answered it.
|
||||||
line := s.get(sent.from, http.StatusOK, requestlog.ActionForward)
|
line := s.get(sent.from, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, sent.refusal)
|
wantWouldAction(t, line, sent.refusal)
|
||||||
|
|
||||||
if line.UpstreamStatus != http.StatusOK {
|
|
||||||
t.Errorf("log line has upstream_status %d, want 200",
|
|
||||||
line.UpstreamStatus)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -133,62 +136,6 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeKeepsTheSizeLimitsAndTheToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
|
||||||
|
|
||||||
var calls atomic.Int32
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
answerWithSize(w, 2*sizeLimit, true)
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
mode: observe,
|
|
||||||
trustedProxies: trustLocalhost,
|
|
||||||
denyNets: denied,
|
|
||||||
requestMaxBytes: sizeLimitSetting,
|
|
||||||
responseMaxBytes: sizeLimitSetting,
|
|
||||||
metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
// SWWAF_DENY_NETS would refuse each request; instead a size limit or
|
|
||||||
// the missing token does.
|
|
||||||
for i, tc := range []struct {
|
|
||||||
method, path string
|
|
||||||
body io.Reader
|
|
||||||
status int
|
|
||||||
action string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
http.MethodPost, "/upload", bytes.NewReader(make([]byte, 2*sizeLimit)),
|
|
||||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
http.MethodGet, "/download", http.NoBody,
|
|
||||||
http.StatusBadGateway, requestlog.ActionTooLarge,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
http.MethodGet, proxy.MetricsPath, http.NoBody,
|
|
||||||
http.StatusUnauthorized, requestlog.ActionAdmin,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
req := newRequest(t, tc.method, addr, tc.path, tc.body)
|
|
||||||
req.Header.Set(forwardedFor, denied)
|
|
||||||
wantStatus(t, do(t, req), tc.status)
|
|
||||||
|
|
||||||
line := out.requestLines(t, i+1)[i]
|
|
||||||
wantLine(t, line, tc.status, tc.action)
|
|
||||||
wantWouldAction(t, line, requestlog.ActionDenied)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The upload was refused before it reached the app.
|
|
||||||
if calls.Load() != 1 {
|
|
||||||
t.Errorf("the app was called %d times, want once", calls.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantWouldAction checks the request log line's would_action, and that a
|
// wantWouldAction checks the request log line's would_action, and that a
|
||||||
// line that should have none has no such field.
|
// line that should have none has no such field.
|
||||||
func wantWouldAction(t *testing.T, line logLine, want string) {
|
func wantWouldAction(t *testing.T, line logLine, want string) {
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
// 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/lookup"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminPrefix starts the path of each of discriminator's own endpoints. The app never
|
||||||
|
// sees a path under it.
|
||||||
|
const adminPrefix = "/_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 *waf.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.
|
||||||
|
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
|
||||||
|
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.Rules{
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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 and the fixes of the apps in SWW_APPS,
|
||||||
|
// 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 waf.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) (*waf.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 := waf.New(waf.Params{
|
||||||
|
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||||
|
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||||
|
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
|
Apps: cfg.Apps,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
rules *rules.Files
|
||||||
|
coreRuleSet *waf.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,10 +1,11 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,88 +17,88 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// shortTimeout is what a test sets a timeout to, to see it run out.
|
|
||||||
// It starts before the test has set up its case, such as an upgrade
|
|
||||||
// or the app's buffers filling, so it is as long as the hold-up of the
|
|
||||||
// test process that wantTimedOut allows: a shorter one can run out
|
|
||||||
// first on a busy host.
|
|
||||||
shortTimeout = waitLimit / 2
|
|
||||||
// longTimeoutSetting is a timeout that does not run out in a test.
|
|
||||||
longTimeoutSetting = "1m"
|
|
||||||
// waitLimit bounds how long a test waits for what should happen.
|
// waitLimit bounds how long a test waits for what should happen.
|
||||||
waitLimit = 10 * time.Second
|
waitLimit = 10 * time.Second
|
||||||
// pollInterval is how often a test looks for a log line.
|
// pollInterval is how often a test looks for a log line.
|
||||||
pollInterval = 10 * time.Millisecond
|
pollInterval = 10 * time.Millisecond
|
||||||
// localhost is where every test server listens, and so the address
|
// localhost is where every test server listens, and so the address rproxy
|
||||||
// smallwebwaf sees each test's requests come from.
|
// sees each test's requests come from.
|
||||||
localhost = "127.0.0.1"
|
localhost = "127.0.0.1"
|
||||||
// requestType is the type that marks a request log line.
|
// requestType is the type that marks a request log line.
|
||||||
requestType = "request"
|
requestType = "request"
|
||||||
// protocol is the protocol of every test's requests.
|
// protocol is the protocol of every question.
|
||||||
protocol = "HTTP/1.1"
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// shortTimeoutSetting is shortTimeout as a setting's value.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // a constant cannot call String
|
|
||||||
var shortTimeoutSetting = shortTimeout.String()
|
|
||||||
|
|
||||||
// The settings the tests set.
|
// The settings the tests set.
|
||||||
const (
|
const (
|
||||||
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT"
|
mode = "SWW_MODE"
|
||||||
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES"
|
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
||||||
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT"
|
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||||
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||||
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT"
|
allowNets = "SWW_ALLOW_NETS"
|
||||||
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
mode = "SWWAF_MODE"
|
denyNets = "SWW_DENY_NETS"
|
||||||
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES"
|
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||||
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
trustedProxies = "SWWAF_TRUSTED_PROXIES"
|
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
allowNets = "SWWAF_ALLOW_NETS"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
denyNets = "SWWAF_DENY_NETS"
|
tagDeny = "SWW_TAG_DENY"
|
||||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
denyKP = "country=KP"
|
||||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
maxBans = "SWW_MAX_BANS"
|
||||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
banResponse = "SWWAF_BAN_RESPONSE"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
maxBans = "SWWAF_MAX_BANS"
|
wafMode = "SWW_WAF_MODE"
|
||||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
|
||||||
instanceName = "SWWAF_INSTANCE_NAME"
|
|
||||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
|
||||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
|
||||||
rulesDir = "SWWAF_RULES_DIR"
|
|
||||||
wafMode = "SWWAF_WAF_MODE"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// off is the value that switches a setting off.
|
// off is the value that switches a setting off.
|
||||||
const off = "off"
|
const off = "off"
|
||||||
|
|
||||||
// output collects what smallwebwaf writes on stdout.
|
// 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 {
|
type output struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buf bytes.Buffer
|
buf bytes.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write adds lines smallwebwaf writes.
|
// Write adds lines sww writes.
|
||||||
func (o *output) Write(p []byte) (int, error) {
|
func (o *output) Write(p []byte) (int, error) {
|
||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
defer o.mu.Unlock()
|
defer o.mu.Unlock()
|
||||||
@@ -176,6 +177,73 @@ func (o *output) requestLine(t *testing.T) logLine {
|
|||||||
return o.requestLines(t, 1)[0]
|
return o.requestLines(t, 1)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// endedLines waits for count requests that went through rproxy, or to 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.
|
// decodeLine reads a request log line's fields into a logLine.
|
||||||
func decodeLine(t *testing.T, fields map[string]any) logLine {
|
func decodeLine(t *testing.T, fields map[string]any) logLine {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -195,7 +263,7 @@ func decodeLine(t *testing.T, fields map[string]any) logLine {
|
|||||||
return line
|
return line
|
||||||
}
|
}
|
||||||
|
|
||||||
// startApp starts app as the app smallwebwaf passes requests to.
|
// startApp starts app as the app rproxy passes requests to.
|
||||||
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -205,9 +273,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
// startProxy starts smallwebwaf in front of the app at appURL, with the
|
// startProxy starts discriminator, and rproxy in front of the app at appURL, each with
|
||||||
// settings in env on top of the defaults, and returns where it listens and
|
// the settings in env on top of the defaults, and returns where rproxy listens
|
||||||
// what it writes.
|
// and what discriminator writes.
|
||||||
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -227,14 +295,14 @@ func startProxyWithGeoJS(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||||
// bans made by the time now tells, and returns the server as well. Unless
|
// bans made by the time now tells, and returns discriminator's server as well, whose
|
||||||
// env sets SWWAF_RULES_DIR, it is an empty directory, of no rules, and
|
// Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty
|
||||||
// unless it sets SWWAF_INSTANCE_NAME, that is app, the label instance of
|
// directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app,
|
||||||
// every metric.
|
// the label instance of every metric.
|
||||||
func startProxyWithClock(
|
func startProxyWithClock(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
) (string, *output, *proxy.Server) {
|
) (string, *output, *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
||||||
@@ -243,14 +311,70 @@ func startProxyWithClock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
||||||
// the alerts the proxy raises as well, as newProxy makes them.
|
// the alerts discriminator raises as well, as newDiscriminator makes them.
|
||||||
func startProxyWithAlerts(
|
func startProxyWithAlerts(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
) (string, *output, *proxy.Server, *alerts.Queue) {
|
) (string, *output, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
server, out, alertQueue := newProxy(t, appURL, geojsURL, now, env)
|
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")
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -265,28 +389,36 @@ func startProxyWithAlerts(
|
|||||||
_ = server.Close()
|
_ = server.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
return listener.Addr().String(), out, server, alertQueue
|
return listener.Addr().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// newProxy makes the server startProxyWithClock starts, without starting
|
// newDiscriminator makes discriminator's server, without starting it, and
|
||||||
// it, and returns it, what it writes, and the queue of the alerts the
|
// returns it, what it writes, and the queue of the alerts it raises, as the
|
||||||
// proxy raises, as the settings in env make it. No alert is sent from the
|
// settings in env make it. No alert is sent from the queue: they wait in it,
|
||||||
// queue: they wait in it, for the test to look at. With no geojsURL, there
|
// for the test to look at.
|
||||||
// is no stand-in for GeoJS to look clients up at, and SWWAF_LOOKUP_SOURCE
|
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
||||||
// is off unless env sets it. While it is file, the lookup database
|
// SWW_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
||||||
// SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
// database SWW_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||||
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off
|
// at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless
|
||||||
// unless env sets it, so that only the tests of the Core Rule Set have
|
// env sets it, so that only the tests of the Core Rule Set have their requests
|
||||||
// their requests inspected by it.
|
// inspected by it.
|
||||||
func newProxy(
|
func newDiscriminator(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
||||||
env map[string]string,
|
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||||
) (*proxy.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()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWWAF_UPSTREAM_URL": appURL, rulesDir: t.TempDir(), instanceName: "app",
|
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
|
||||||
wafMode: off,
|
|
||||||
}
|
}
|
||||||
if geojsURL == "" {
|
if geojsURL == "" {
|
||||||
settings[lookupSource] = off
|
settings[lookupSource] = off
|
||||||
@@ -334,13 +466,19 @@ func newProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server := proxy.New(proxy.Params{
|
coreRuleSet, err := discriminator.NewCoreRuleSet(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Core Rule Set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := build(discriminator.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: out,
|
RequestLog: out,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
GeoJSURL: geojsURL,
|
GeoJSURL: geojsURL,
|
||||||
AbuseIPDBURL: abuseIPDBURL,
|
AbuseIPDBURL: abuseIPDBURL,
|
||||||
LookupFile: lookupFile,
|
LookupFile: lookupFile,
|
||||||
|
CoreRuleSet: coreRuleSet,
|
||||||
Now: now,
|
Now: now,
|
||||||
Rules: ruleFiles,
|
Rules: ruleFiles,
|
||||||
Alerts: alertQueue,
|
Alerts: alertQueue,
|
||||||
@@ -378,7 +516,7 @@ func readAnswer(res *http.Response) answer {
|
|||||||
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRequest makes a request for path to smallwebwaf at addr.
|
// newRequest makes a request for path to addr, where rproxy or discriminator listens.
|
||||||
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -402,15 +540,15 @@ func do(t *testing.T, req *http.Request) answer {
|
|||||||
return readAnswer(res)
|
return readAnswer(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get sends a GET request for path to smallwebwaf at addr.
|
// get sends a GET request for path to addr.
|
||||||
func get(t *testing.T, addr, path string) answer {
|
func get(t *testing.T, addr, path string) answer {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return do(t, newRequest(t, http.MethodGet, addr, path, http.NoBody))
|
return do(t, newRequest(t, http.MethodGet, addr, path, http.NoBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
// dial opens a connection to smallwebwaf at addr, for requests the HTTP
|
// dial opens a connection to addr, for requests the HTTP client cannot make,
|
||||||
// client cannot make, such as one that stops sending halfway.
|
// such as one that stops sending halfway.
|
||||||
func dial(t *testing.T, addr string) net.Conn {
|
func dial(t *testing.T, addr string) net.Conn {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -471,14 +609,3 @@ func wantStatus(t *testing.T, got answer, status int) {
|
|||||||
t.Errorf("status %d, want %d", got.status, status)
|
t.Errorf("status %d, want %d", got.status, status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantTimedOut checks that what began at start ended once shortTimeout
|
|
||||||
// had run out, and not much later.
|
|
||||||
func wantTimedOut(t *testing.T, start time.Time) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
took := time.Since(start)
|
|
||||||
if took < shortTimeout || took > shortTimeout+waitLimit/2 {
|
|
||||||
t.Errorf("took %s, want %s", took, shortTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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.limitPercent, rq.bytesPercent = rq.limitPercentages()
|
||||||
|
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
|
||||||
|
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
|
||||||
|
|
||||||
|
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
||||||
|
rq.limitPercent.percent)
|
||||||
|
rq.line.Counts = counts
|
||||||
|
|
||||||
|
if !over {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
||||||
|
|
||||||
|
return &verdict{action: requestlog.ActionRateLimited, figures: limitFigures(hit)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
|
||||||
|
// request at now, whatever its client's limit percentages: the rate limits
|
||||||
|
// count it, and one more request from its client takes it over a whole rate
|
||||||
|
// limit. It counts nothing.
|
||||||
|
func (rq *request) overAWholeRateLimit(now time.Time) bool {
|
||||||
|
return rq.countedByRateLimits() &&
|
||||||
|
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countedByRateLimits reports whether the rate limits count the request: its
|
||||||
|
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
||||||
|
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
|
||||||
|
func (rq *request) countedByRateLimits() bool {
|
||||||
|
cfg := rq.h.config
|
||||||
|
|
||||||
|
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
||||||
|
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// minute is the window of SWWAF_RATE_LIMIT_PER_MINUTE, as a log line's
|
// minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's
|
||||||
// limit_hit names it.
|
// limit_hit names it.
|
||||||
const minute = "minute"
|
const minute = "minute"
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|||||||
req.Header.Set(forwardedFor, sent.client)
|
req.Header.Set(forwardedFor, sent.client)
|
||||||
wantStatus(t, do(t, req), sent.want)
|
wantStatus(t, do(t, req), sent.want)
|
||||||
|
|
||||||
line := out.requestLines(t, i+1)[i]
|
line := out.endedLines(t, i+1)[i]
|
||||||
if line.ClientIP != sent.logged {
|
if line.ClientIP != sent.logged {
|
||||||
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged)
|
t.Errorf("log line has client_ip %q, want %q", line.ClientIP, sent.logged)
|
||||||
}
|
}
|
||||||
@@ -72,10 +72,21 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRateLimitRefusalNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
|
|
||||||
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
wantHeuristic(t, line, "rate_limits", `{"window": "minute", "count": 2, "limit": 1}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// With SWWAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
// With SWW_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
||||||
// /64s of one /48, are one client, and the second's request breaks the
|
// /64s of one /48, are one client, and the second's request breaks the
|
||||||
// limit; the third, in the next /48, is another client.
|
// limit; the third, in the next /48, is another client.
|
||||||
const (
|
const (
|
||||||
@@ -118,14 +129,14 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
|||||||
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
rateLimitExemptPaths: "/assets/,/favicon.ico",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
// The answers are kept before the requests, so that none waits for
|
// The answers are kept before the requests, so that none waits for
|
||||||
@@ -151,12 +162,12 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
// /assets/, and breaks the limit.
|
// /assets/, and breaks the limit.
|
||||||
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
// A ban, SWWAF_DENY_NETS and the country lists still refuse a path
|
// A ban, SWW_DENY_NETS and SWW_TAG_DENY still refuse a path under a
|
||||||
// under a prefix.
|
// prefix.
|
||||||
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
||||||
s.request(fromKP, "/assets/app.js",
|
s.request(fromKP, "/assets/app.js",
|
||||||
http.StatusForbidden, requestlog.ActionCountryDenied)
|
http.StatusForbidden, requestlog.ActionDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
|
||||||
|
// SWW_BAN_RESPONSE.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// refusalLimit counts the request once it has been answered, if discriminator
|
||||||
|
// refused it after a rule file match, a trap path or a Core Rule Set match, and
|
||||||
|
// in observe mode if enforce mode would have: more than
|
||||||
|
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
|
||||||
|
// a limit, which bans the client. A client in SWW_ALLOW_NETS, which the
|
||||||
|
// heuristics skip, is not counted, and nothing is while the threshold is off.
|
||||||
|
// Its figures are the window, the count and the limit.
|
||||||
|
type refusalLimit struct{}
|
||||||
|
|
||||||
|
func (refusalLimit) name() string { return "refusal_limit" }
|
||||||
|
|
||||||
|
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
|
||||||
|
cfg := rq.h.config
|
||||||
|
if cfg.ErrorBurstThreshold == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
||||||
|
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.ActionRuleBlocked, requestlog.ActionWAFBlocked:
|
||||||
|
status = http.StatusForbidden
|
||||||
|
case requestlog.ActionBanned:
|
||||||
|
status = cfg.BanResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.banForLimit(now, hit, status)
|
||||||
|
|
||||||
|
return &verdict{action: requestlog.ActionBanned, figures: limitFigures(hit)}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
|
|
||||||
// refused is a request the tests here send, which smallwebwaf refuses
|
// refused is a request the tests here send, which discriminator refuses after a rule
|
||||||
// after a rule file match, or for a missing or wrong token.
|
// file match.
|
||||||
type refused int
|
type refused int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -27,11 +27,6 @@ const (
|
|||||||
// banRule is one its ban rule refuses with 403, and bans the client
|
// banRule is one its ban rule refuses with 403, and bans the client
|
||||||
// for.
|
// for.
|
||||||
banRule
|
banRule
|
||||||
// noMetricsToken is one for the metrics without a token, and
|
|
||||||
// wrongAdminToken one for the bans with the metrics token, each
|
|
||||||
// refused with 401.
|
|
||||||
noMetricsToken
|
|
||||||
wrongAdminToken
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// send sends r from the client at from, checks its answer and log line as
|
// send sends r from the client at from, checks its answer and log line as
|
||||||
@@ -45,14 +40,6 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionRuleBlocked)
|
||||||
case banRule:
|
case banRule:
|
||||||
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
case noMetricsToken:
|
|
||||||
return s.request(from, proxy.MetricsPath, http.StatusUnauthorized,
|
|
||||||
requestlog.ActionAdmin)
|
|
||||||
case wrongAdminToken:
|
|
||||||
line, _ := s.requestWithHeader(from, proxy.BansPath, "Authorization: "+bearer,
|
|
||||||
http.StatusUnauthorized, requestlog.ActionAdmin)
|
|
||||||
|
|
||||||
return line
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.t.Fatalf("no request for the refusal %d", r)
|
s.t.Fatalf("no request for the refusal %d", r)
|
||||||
@@ -60,17 +47,16 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
return logLine{}
|
return logLine{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startForErrorBurst is startWithClock with testRules, both tokens and
|
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
||||||
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||||
func startForErrorBurst(
|
func startForErrorBurst(
|
||||||
t *testing.T, threshold string, env map[string]string,
|
t *testing.T, threshold string, env map[string]string,
|
||||||
) (*sender, *clock, *proxy.Server) {
|
) (*sender, *clock, *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
errorBurstThreshold: threshold,
|
errorBurstThreshold: threshold,
|
||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
adminToken: adminSecret,
|
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
@@ -87,14 +73,7 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
refusals []refused
|
refusals []refused
|
||||||
}{
|
}{
|
||||||
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
|
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
|
||||||
{
|
{"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}},
|
||||||
"missing or wrong token",
|
|
||||||
[]refused{noMetricsToken, wrongAdminToken, noMetricsToken, wrongAdminToken},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a mix ending in a ban rule",
|
|
||||||
[]refused{blockRule, noMetricsToken, blockRule, banRule},
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -127,16 +106,33 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const scraper = "192.0.2.200"
|
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)
|
s, clk, server := startForErrorBurst(t, "2", nil)
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
blockRule.send(s, client)
|
blockRule.send(s, client)
|
||||||
wrongAdminToken.send(s, client)
|
blockRule.send(s, client)
|
||||||
line := blockRule.send(s, client)
|
line := blockRule.send(s, client)
|
||||||
|
|
||||||
expires := start.Add(time.Hour)
|
expires := start.Add(time.Hour)
|
||||||
@@ -173,20 +169,18 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
|||||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 2, TokenRefused: 1}
|
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3}
|
||||||
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
||||||
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape(scraper)
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="refusals",window="minute"}`, 1)
|
`kind="refusals",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 2)
|
`sww_offences_total{instance="app",kind="rule_blocked"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
`smallwebwaf_offences_total{instance="app",kind="token_refused"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||||
@@ -197,7 +191,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
errorBurstThreshold: "2",
|
errorBurstThreshold: "2",
|
||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
countryLimitPercent: countryDEHalf,
|
tagLimitPercent: countryTagDEHalf,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Half of the threshold would be one, which the second refusal is over.
|
// Half of the threshold would be one, which the second refusal is over.
|
||||||
@@ -283,27 +277,23 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
||||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS still needs the token, but is not
|
// A client in SWW_ALLOW_NETS is not refused, and so not counted.
|
||||||
// counted.
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
line := noMetricsToken.send(s, allowed)
|
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
if line.LimitHit != "" {
|
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One the rate limits do not apply to is.
|
// One the rate limits do not apply to is.
|
||||||
noMetricsToken.send(s, exempt)
|
blockRule.send(s, exempt)
|
||||||
|
|
||||||
line := wrongAdminToken.send(s, exempt)
|
line := blockRule.send(s, exempt)
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst {
|
if line.LimitHit != requestlog.LimitHitErrorBurst {
|
||||||
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
||||||
}
|
}
|
||||||
@@ -336,7 +326,6 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|||||||
mode: observe,
|
mode: observe,
|
||||||
errorBurstThreshold: "1",
|
errorBurstThreshold: "1",
|
||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
adminToken: adminSecret,
|
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
held := bans.Ban{
|
held := bans.Ban{
|
||||||
@@ -347,10 +336,11 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|||||||
}
|
}
|
||||||
server.Ledger.Load([]bans.Ban{held})
|
server.Ledger.Load([]bans.Ban{held})
|
||||||
|
|
||||||
// Under a ban, enforce mode would have refused these before the
|
// Under a ban, enforce mode would have refused these before the rule, so
|
||||||
// endpoint, so their tokens are not counted.
|
// they are not counted.
|
||||||
for range 2 {
|
for range 2 {
|
||||||
line := wrongAdminToken.send(s, otherClient)
|
line := s.request(otherClient, blockedPath, http.StatusOK,
|
||||||
|
requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionBanned)
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
|
|
||||||
if line.LimitHit != "" {
|
if line.LimitHit != "" {
|
||||||
@@ -360,7 +350,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|||||||
|
|
||||||
// The block rule's refusal, which enforce mode would have answered 403,
|
// The block rule's refusal, which enforce mode would have answered 403,
|
||||||
// is the second of the client's, and would have banned it.
|
// is the second of the client's, and would have banned it.
|
||||||
wrongAdminToken.send(s, client)
|
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
|
||||||
|
// abuseipdb.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
|
||||||
|
// refuses the requests of a client a source lists.
|
||||||
|
const deny = "deny"
|
||||||
|
|
||||||
|
// noteListed notes each of sources, the URLs of the blocklists or the
|
||||||
|
// DNSBL zones, their keys masked, that list the client, as noteHit does,
|
||||||
|
// with reason.
|
||||||
|
func (rq *request) noteListed(sources []string, reason string) {
|
||||||
|
for _, source := range sources {
|
||||||
|
rq.noteHit(bans.ReputationHit{Source: source}, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteHit adds hit's source, which lists the client, to the log line's
|
||||||
|
// reputation, and hit to the notes of a ban the request makes, counts the
|
||||||
|
// source in the metrics, and raises a reputation_hit alert with reason,
|
||||||
|
// whose detail gives hit's source and score.
|
||||||
|
func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
||||||
|
detail := map[string]any{"source": hit.Source}
|
||||||
|
if hit.Score != nil {
|
||||||
|
detail["score"] = *hit.Score
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.line.Reputation = append(rq.line.Reputation, hit.Source)
|
||||||
|
rq.reputation = append(rq.reputation, hit)
|
||||||
|
rq.h.metrics.ReputationHit(hit.Source)
|
||||||
|
rq.h.alerts.Raise(alerts.Alert{
|
||||||
|
Event: alerts.EventReputationHit,
|
||||||
|
Host: rq.site,
|
||||||
|
Client: rq.client,
|
||||||
|
Netblock: rq.h.clientGroup(rq.client),
|
||||||
|
ASN: rq.line.ASN,
|
||||||
|
ASName: rq.line.ASName,
|
||||||
|
Country: rq.line.Country,
|
||||||
|
Reason: reason,
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
@@ -23,12 +23,12 @@ import (
|
|||||||
|
|
||||||
// The reputation settings.
|
// The reputation settings.
|
||||||
const (
|
const (
|
||||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWWAF_BLOCKLIST_ACTION and SWWAF_REPUTATION_ACTION:
|
// The actions of SWW_BLOCKLIST_ACTION and SWW_REPUTATION_ACTION:
|
||||||
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
||||||
// quarter.
|
// quarter.
|
||||||
const (
|
const (
|
||||||
@@ -118,16 +118,66 @@ func TestEachBlocklistActionForAListedAddressAndAListedNetblock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
|
func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
heuristic string
|
||||||
|
env map[string]string
|
||||||
|
load func(*testing.T, *discriminator.Server)
|
||||||
|
figures string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"blocklists",
|
||||||
|
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
|
||||||
|
func(t *testing.T, server *discriminator.Server) {
|
||||||
|
t.Helper()
|
||||||
|
loadLists(t, server, map[string][]string{dropURL: {client}})
|
||||||
|
},
|
||||||
|
`{"lists": ["` + dropURL + `"]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dnsbl",
|
||||||
|
map[string]string{
|
||||||
|
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||||
|
reputationAction: actionDeny,
|
||||||
|
},
|
||||||
|
func(_ *testing.T, server *discriminator.Server) {
|
||||||
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||||
|
},
|
||||||
|
`{"zones": ["` + dnsblZone + `"]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"abuseipdb",
|
||||||
|
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
||||||
|
func(_ *testing.T, server *discriminator.Server) {
|
||||||
|
loadScores(server, map[string]int64{client: 80})
|
||||||
|
},
|
||||||
|
`{"score": 80, "min_score": 75}`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.heuristic, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, tc.env)
|
||||||
|
tc.load(t, server)
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
wantHeuristic(t, line, tc.heuristic, tc.figures)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlocklistsComeAfterAllowedCountriesAndSkipAllowNets(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, queue := startWithLookups(t, map[string]string{
|
s, server, queue := startWithLookups(t, map[string]string{
|
||||||
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE,
|
blocklistURLs: dropURL, allowedCountries: "de", allowNets: fromDE,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
||||||
|
|
||||||
// fromKP's country refuses it before the list is looked at, and fromDE,
|
// fromKP's country refuses it before the list is looked at, and fromDE,
|
||||||
// in SWWAF_ALLOW_NETS, is not checked at all: neither is noted, nor
|
// in SWW_ALLOW_NETS, is not checked at all: neither is noted, nor
|
||||||
// alerted.
|
// alerted.
|
||||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
||||||
@@ -161,18 +211,18 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
|
|||||||
// percentText gives them, and limitHit its limit_hit.
|
// percentText gives them, and limitHit its limit_hit.
|
||||||
want, limitHit string
|
want, limitHit string
|
||||||
}{
|
}{
|
||||||
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
{limitHalf, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
|
||||||
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes},
|
{limitQuarter, asnTagDEHalf, "25 from " + blocklistAction, minuteBytes},
|
||||||
// The AS number's, the first of two alike.
|
// The AS number's, the first of two alike.
|
||||||
{limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
|
{limitQuarter, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
|
||||||
{actionLog, asnDE + ":100", none, ""},
|
{actionLog, "asn=64511:25", none, ""},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
|
||||||
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent,
|
blocklistAction: tc.action, tagLimitPercent: tc.asnPercent,
|
||||||
})
|
})
|
||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
|
||||||
|
|
||||||
@@ -196,7 +246,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
fromURL = "25 from " + asnLimitPercentURL
|
fromURL = "25 from " + asnLimitPercentURL
|
||||||
fromSetting = "25 from " + asnLimitPercent
|
fromSetting = "25 from " + tagLimitPercent
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -210,21 +260,21 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
|
||||||
{
|
{
|
||||||
"the file's, lower than the setting's",
|
"the file's, lower than the setting's",
|
||||||
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL,
|
map[string]string{tagLimitPercent: asnTagDEHalf}, asnDEQuarter, fromURL, fromURL,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the setting's, lower than the file's",
|
"the setting's, lower than the file's",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf,
|
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEHalf,
|
||||||
fromSetting, fromSetting,
|
fromSetting, fromSetting,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the setting's, the first of two alike",
|
"the setting's, the first of two alike",
|
||||||
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter,
|
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEQuarter,
|
||||||
fromSetting, fromSetting,
|
fromSetting, fromSetting,
|
||||||
},
|
},
|
||||||
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
||||||
{
|
{
|
||||||
"SWWAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
"SWW_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
||||||
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -272,6 +322,7 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
Instance: alertInstance,
|
Instance: alertInstance,
|
||||||
Time: clk.Now(),
|
Time: clk.Now(),
|
||||||
Event: alerts.EventReputationHit,
|
Event: alerts.EventReputationHit,
|
||||||
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(fromDE),
|
Client: netip.MustParseAddr(fromDE),
|
||||||
Netblock: netip.MustParsePrefix(fromDE + "/32"),
|
Netblock: netip.MustParsePrefix(fromDE + "/32"),
|
||||||
ASN: asnDE,
|
ASN: asnDE,
|
||||||
@@ -289,7 +340,7 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
|
|
||||||
// Each list's hits, none of its fetches failed, and when its copy was
|
// Each list's hits, none of its fetches failed, and when its copy was
|
||||||
// fetched, 0 for the one without.
|
// fetched, 0 for the one without.
|
||||||
metrics := s.scrape(unplaced)
|
metrics := s.scrape()
|
||||||
fetched := float64(listsFetched().Unix())
|
fetched := float64(listsFetched().Unix())
|
||||||
|
|
||||||
for listURL, want := range map[string]struct{ hits, fetched float64 }{
|
for listURL, want := range map[string]struct{ hits, fetched float64 }{
|
||||||
@@ -298,22 +349,22 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
|
||||||
|
|
||||||
if want.hits == 0 {
|
if want.hits == 0 {
|
||||||
wantNoSeries(t, metrics, "smallwebwaf_reputation_hits_total"+labels)
|
wantNoSeries(t, metrics, "sww_reputation_hits_total"+labels)
|
||||||
} else {
|
} else {
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, want.hits)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, want.hits)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
||||||
want.fetched)
|
want.fetched)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The DNSBL settings.
|
// The DNSBL settings.
|
||||||
const (
|
const (
|
||||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
dnsblZones = "SWW_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
reputationAction = "SWW_REPUTATION_ACTION"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The DNSBL zones these tests name, which are never asked about the
|
// The DNSBL zones these tests name, which are never asked about the
|
||||||
@@ -369,7 +420,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
|||||||
for _, from := range []string{fromDE, fromKP} {
|
for _, from := range []string{fromDE, fromKP} {
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
line := s.get(from, tc.statuses[i], tc.actions[i])
|
line := s.get(from, tc.statuses[i], tc.actions[i])
|
||||||
// In the order SWWAF_DNSBL_ZONES names them.
|
// In the order SWW_DNSBL_ZONES names them.
|
||||||
wantReputation(t, line, listedBy[from]...)
|
wantReputation(t, line, listedBy[from]...)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent,
|
wantPercent(t, "limit_percent", line.LimitPercent,
|
||||||
line.LimitPercentSetting, tc.percent)
|
line.LimitPercentSetting, tc.percent)
|
||||||
@@ -415,7 +466,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
||||||
|
|
||||||
// The blocklist refuses fromKP before its verdict is looked at, and
|
// The blocklist refuses fromKP before its verdict is looked at, and
|
||||||
// fromDE, in SWWAF_ALLOW_NETS, is not checked at all: neither is noted
|
// fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
|
||||||
// for the zone, nor alerted, nor asked about.
|
// for the zone, nor alerted, nor asked about.
|
||||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
dropURL)
|
dropURL)
|
||||||
@@ -431,6 +482,46 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
load func(*discriminator.Server)
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"dnsbl",
|
||||||
|
map[string]string{
|
||||||
|
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
|
||||||
|
},
|
||||||
|
func(server *discriminator.Server) {
|
||||||
|
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||||
|
},
|
||||||
|
[]string{"asn=64496", "country=DE", "dnsbl=" + dnsblZone},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
abuseipdb,
|
||||||
|
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
|
||||||
|
func(server *discriminator.Server) {
|
||||||
|
loadScores(server, map[string]int64{fromDE: 80})
|
||||||
|
},
|
||||||
|
[]string{abuseipdb, "asn=64496", "country=DE"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, tc.env)
|
||||||
|
tc.load(server)
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
tc.want...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -513,6 +604,7 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
Instance: alertInstance,
|
Instance: alertInstance,
|
||||||
Time: clk.Now(),
|
Time: clk.Now(),
|
||||||
Event: alerts.EventReputationHit,
|
Event: alerts.EventReputationHit,
|
||||||
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(fromDE),
|
Client: netip.MustParseAddr(fromDE),
|
||||||
Netblock: netip.MustParsePrefix(fromDE + "/32"),
|
Netblock: netip.MustParsePrefix(fromDE + "/32"),
|
||||||
ASN: asnDE,
|
ASN: asnDE,
|
||||||
@@ -530,14 +622,14 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
|
|
||||||
// Each zone's hits, and its queries and their failures, none, since
|
// Each zone's hits, and its queries and their failures, none, since
|
||||||
// every client had its verdicts.
|
// every client had its verdicts.
|
||||||
metrics := s.scrape(unplaced)
|
metrics := s.scrape()
|
||||||
|
|
||||||
for _, zone := range []string{dnsblZone, otherZone} {
|
for _, zone := range []string{dnsblZone, otherZone} {
|
||||||
labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,8 +656,8 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
|
|||||||
masked)
|
masked)
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape(unplaced)
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+
|
wantMetric(t, metrics, `sww_reputation_hits_total{instance="`+
|
||||||
alertInstance+`",source="`+masked+`"}`, 1)
|
alertInstance+`",source="`+masked+`"}`, 1)
|
||||||
|
|
||||||
for name, shown := range map[string]string{
|
for name, shown := range map[string]string{
|
||||||
@@ -607,7 +699,7 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
|
|||||||
|
|
||||||
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
||||||
const (
|
const (
|
||||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||||
accountKey = "abuseipdb-key-0123456789abcdef"
|
accountKey = "abuseipdb-key-0123456789abcdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -615,7 +707,7 @@ const (
|
|||||||
// AbuseIPDB.
|
// AbuseIPDB.
|
||||||
const abuseipdb = reputation.AbuseIPDBSource
|
const abuseipdb = reputation.AbuseIPDBSource
|
||||||
|
|
||||||
// abuseIPDBURL is where newProxy has clients checked with AbuseIPDB: at
|
// abuseIPDBURL is where newDiscriminator has clients checked with AbuseIPDB: at
|
||||||
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
|
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
|
||||||
// through which AbuseIPDB is asked.
|
// through which AbuseIPDB is asked.
|
||||||
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
||||||
@@ -737,10 +829,6 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
|
|||||||
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
|
"a trap path", "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned,
|
||||||
ratelimit.Offences{Attack: 1},
|
ratelimit.Offences{Attack: 1},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"a missing token", proxy.MetricsPath, http.StatusUnauthorized,
|
|
||||||
requestlog.ActionAdmin, ratelimit.Offences{TokenRefused: 1},
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -748,7 +836,7 @@ func TestClientRefusedForAnOffenceIsCheckedWithAbuseIPDBAtItsNextRequest(t *test
|
|||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||||
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
|
rulesDir: writeRules(t, testRules), attackBanDuration: "1h",
|
||||||
trapPaths: trapPathList, metricsToken: token, wafMode: block,
|
trapPaths: trapPathList, wafMode: block,
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, tc.path, tc.status, tc.action)
|
s.request(client, tc.path, tc.status, tc.action)
|
||||||
@@ -802,7 +890,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
|
|||||||
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
||||||
reputationAction: tc.action,
|
reputationAction: tc.action,
|
||||||
})
|
})
|
||||||
// At SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
// At SWW_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
||||||
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
||||||
|
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
@@ -853,7 +941,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
|||||||
// The alert is made as a DNSBL zone's is, with the score besides.
|
// The alert is made as a DNSBL zone's is, with the score besides.
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
||||||
waiting[0].Reason != "scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE" ||
|
waiting[0].Reason != "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE" ||
|
||||||
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
||||||
queue.Suppressed() != 1 {
|
queue.Suppressed() != 1 {
|
||||||
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
||||||
@@ -862,13 +950,13 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
|||||||
|
|
||||||
// The hits, and the checks, none, since no client committed an
|
// The hits, and the checks, none, since no client committed an
|
||||||
// offence, so that the whole budget is left.
|
// offence, so that the whole budget is left.
|
||||||
metrics := s.scrape(unplaced)
|
metrics := s.scrape()
|
||||||
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_daily_budget_remaining"+labels, 900)
|
wantMetric(t, metrics, "sww_reputation_daily_budget_remaining"+labels, 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
||||||
@@ -889,7 +977,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
|||||||
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||||
wantAbuseIPDBChecks(t, server, 0)
|
wantAbuseIPDBChecks(t, server, 0)
|
||||||
|
|
||||||
wantNoSeries(t, s.scrape(unplaced), `smallwebwaf_reputation_daily_budget_remaining{`+
|
wantNoSeries(t, s.scrape(), `sww_reputation_daily_budget_remaining{`+
|
||||||
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
|
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -924,7 +1012,7 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
|
|||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||||
})
|
})
|
||||||
// Every source lists client, and none otherClient, whose score is
|
// Every source lists client, and none otherClient, whose score is
|
||||||
// under SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
// under SWW_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||||
loadLists(t, server, map[string][]string{dropURL: {client}})
|
loadLists(t, server, map[string][]string{dropURL: {client}})
|
||||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
|
||||||
loadScores(server, map[string]int64{client: score, otherClient: 74})
|
loadScores(server, map[string]int64{client: score, otherClient: 74})
|
||||||
@@ -973,11 +1061,11 @@ func verdictsFetched() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
||||||
// a verdict of each zone SWWAF_DNSBL_ZONES names, fetched at
|
// a verdict of each zone SWW_DNSBL_ZONES names, fetched at
|
||||||
// verdictsFetched, as reputation.json would at start: one that lists the
|
// verdictsFetched, as reputation.json would at start: one that lists the
|
||||||
// client from each zone listedBy gives for it, and one that does not from
|
// client from each zone listedBy gives for it, and one that does not from
|
||||||
// each other zone.
|
// each other zone.
|
||||||
func loadVerdicts(server *proxy.Server, listedBy map[string][]string) {
|
func loadVerdicts(server *discriminator.Server, listedBy map[string][]string) {
|
||||||
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
|
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
|
||||||
|
|
||||||
for client, zones := range listedBy {
|
for client, zones := range listedBy {
|
||||||
@@ -995,7 +1083,7 @@ func loadVerdicts(server *proxy.Server, listedBy map[string][]string) {
|
|||||||
// loadScores puts into server's AbuseIPDB the score scores gives each
|
// loadScores puts into server's AbuseIPDB the score scores gives each
|
||||||
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
||||||
// would at start.
|
// would at start.
|
||||||
func loadScores(server *proxy.Server, scores map[string]int64) {
|
func loadScores(server *discriminator.Server, scores map[string]int64) {
|
||||||
kept := make([]reputation.Score, 0, len(scores))
|
kept := make([]reputation.Score, 0, len(scores))
|
||||||
for client, score := range scores {
|
for client, score := range scores {
|
||||||
kept = append(kept, reputation.Score{
|
kept = append(kept, reputation.Score{
|
||||||
@@ -1009,7 +1097,7 @@ func loadScores(server *proxy.Server, scores map[string]int64) {
|
|||||||
|
|
||||||
// wantAbuseIPDBChecks checks how many clients server has checked with
|
// wantAbuseIPDBChecks checks how many clients server has checked with
|
||||||
// AbuseIPDB.
|
// AbuseIPDB.
|
||||||
func wantAbuseIPDBChecks(t *testing.T, server *proxy.Server, want int) {
|
func wantAbuseIPDBChecks(t *testing.T, server *discriminator.Server, want int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if got := server.AbuseIPDB.Checked(); got != want {
|
if got := server.AbuseIPDB.Checked(); got != want {
|
||||||
@@ -1019,7 +1107,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *proxy.Server, want int) {
|
|||||||
|
|
||||||
// loadLists puts copies of lists into server's lists, by URL, each with
|
// loadLists puts copies of lists into server's lists, by URL, each with
|
||||||
// its lines, fetched at listsFetched, as reputation.json would at start.
|
// its lines, fetched at listsFetched, as reputation.json would at start.
|
||||||
func loadLists(t *testing.T, server *proxy.Server, copies map[string][]string) {
|
func loadLists(t *testing.T, server *discriminator.Server, copies map[string][]string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
lists := make([]reputation.List, 0, len(copies))
|
lists := make([]reputation.List, 0, len(copies))
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
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. limitPercent and
|
||||||
|
// bytesPercent are then its client's limit percentages for the rate
|
||||||
|
// limits and for the byte limits.
|
||||||
|
counted bool
|
||||||
|
limitPercent, bytesPercent percentage
|
||||||
|
// attack is true for a request that matched a ban rule or asked for a
|
||||||
|
// trap path, ruleBlocked for one a block rule refused, and wafBlocked for
|
||||||
|
// one the Core Rule Set refused, each an offence its client's history
|
||||||
|
// counts.
|
||||||
|
attack, ruleBlocked, wafBlocked 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.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
|
||||||
|
return &refusal{status: http.StatusForbidden, action: action}
|
||||||
|
case action == requestlog.ActionTooLarge:
|
||||||
|
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.DurationTotal = requestlog.Milliseconds(duration)
|
||||||
|
line.DurationChecks = 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,
|
||||||
|
RuleBlocked: rq.ruleBlocked,
|
||||||
|
WAFBlocked: rq.wafBlocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,8 +1,8 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
@@ -19,8 +19,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
// requestIDHeader carries the request's id.
|
// requestIDHeader carries the request's id.
|
||||||
requestIDHeader = "X-Request-ID"
|
requestIDHeader = "X-Request-ID"
|
||||||
// instance is the SWWAF_INSTANCE_NAME a test sets.
|
// instance is the SWW_INSTANCE_NAME a test sets.
|
||||||
instance = "fsn1app1/gitea"
|
instance = "fsn1app1/blog"
|
||||||
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
||||||
// limits count it as.
|
// limits count it as.
|
||||||
ipv6Client = "2001:db8::7"
|
ipv6Client = "2001:db8::7"
|
||||||
@@ -42,9 +42,6 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
w.Header().Set("Location", "/elsewhere")
|
|
||||||
w.WriteHeader(http.StatusFound)
|
w.WriteHeader(http.StatusFound)
|
||||||
_, _ = io.WriteString(w, "moved")
|
_, _ = io.WriteString(w, "moved")
|
||||||
})
|
})
|
||||||
@@ -70,7 +67,7 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
|||||||
|
|
||||||
// A request's log line can come after its answer: each is waited for
|
// 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.
|
// before the next request, so that the lines are in order.
|
||||||
full := out.requestLines(t, 1)[0]
|
full := out.endedLines(t, 1)[0]
|
||||||
|
|
||||||
// This one comes from 127.0.0.1, which the rate limits do not count,
|
// 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
|
// with a body of 4 bytes whose length it does not announce, so that its
|
||||||
@@ -81,7 +78,7 @@ func TestLogLineHasEachFieldWhereItApplies(t *testing.T) {
|
|||||||
"Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n")
|
"Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n")
|
||||||
wantStatus(t, readResponse(t, conn), http.StatusNoContent)
|
wantStatus(t, readResponse(t, conn), http.StatusNoContent)
|
||||||
|
|
||||||
bare := out.requestLines(t, 2)[1]
|
bare := out.endedLines(t, 2)[1]
|
||||||
|
|
||||||
wantFullLine(t, full)
|
wantFullLine(t, full)
|
||||||
wantBareLine(t, bare)
|
wantBareLine(t, bare)
|
||||||
@@ -108,24 +105,23 @@ func wantFullLine(t *testing.T, line logLine) {
|
|||||||
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
||||||
|
|
||||||
want := withTimings(line, requestlog.Line{
|
want := withTimings(line, requestlog.Line{
|
||||||
Type: requestType, Time: line.Time, Instance: instance,
|
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||||
|
Time: line.Time, Instance: instance,
|
||||||
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
||||||
Host: appHost, Path: "/full", Protocol: protocol,
|
Host: appHost, Path: "/full", Protocol: protocol,
|
||||||
Status: http.StatusFound, RequestBytes: 3, ResponseBytes: 5,
|
Status: http.StatusFound, RequestBytes: 3, ResponseBytes: 5,
|
||||||
RequestID: "from-traefik", PeerIP: localhost,
|
RequestID: line.RequestID, PeerIP: localhost,
|
||||||
ForwardedFor: "198.51.100.7, " + ipv6Client, ClientGroup: ipv6Group,
|
ForwardedFor: "198.51.100.7, " + ipv6Client, ClientGroup: ipv6Group,
|
||||||
ContentType: "application/x-www-form-urlencoded", ContentLength: 3,
|
ContentType: "application/x-www-form-urlencoded", ContentLength: 3,
|
||||||
RequestHeaders: headers, HasAuthorization: true, HasCookie: true,
|
RequestHeaders: headers, HasAuthorization: true, HasCookie: true,
|
||||||
ResponseContentType: "text/html", UpstreamStatus: http.StatusFound,
|
|
||||||
CacheControl: "no-store", Location: "/elsewhere",
|
|
||||||
Action: requestlog.ActionForward,
|
Action: requestlog.ActionForward,
|
||||||
// Its 3 bytes in and 5 out, each way counted by default.
|
// Its 3 bytes in and 5 out, each way counted by default.
|
||||||
Counts: ratelimit.Counts{
|
Counts: ratelimit.Counts{
|
||||||
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8,
|
Minute: 1, Hour: 1, Day: 1, MinuteBytes: 8, HourBytes: 8, DayBytes: 8,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if !reflect.DeepEqual(line.Line, want) {
|
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "from-traefik" {
|
||||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
t.Errorf("log line\n%+v\nwant\n%+v, with a new request id", line.Line, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,12 +131,13 @@ func wantBareLine(t *testing.T, line logLine) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
want := withTimings(line, requestlog.Line{
|
want := withTimings(line, requestlog.Line{
|
||||||
Type: requestType, Time: line.Time, Instance: instance,
|
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||||
|
Time: line.Time, Instance: instance,
|
||||||
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
||||||
Host: appHost, Path: "/bare", Protocol: protocol,
|
Host: appHost, Path: "/bare", Protocol: protocol,
|
||||||
Status: http.StatusNoContent, RequestBytes: 4, RequestID: line.RequestID,
|
Status: http.StatusNoContent, RequestBytes: 4, RequestID: line.RequestID,
|
||||||
PeerIP: localhost, ClientGroup: localhost + "/32",
|
PeerIP: localhost, ClientGroup: localhost + "/32",
|
||||||
UpstreamStatus: http.StatusNoContent, Action: requestlog.ActionForward,
|
Action: requestlog.ActionForward,
|
||||||
})
|
})
|
||||||
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" {
|
if !reflect.DeepEqual(line.Line, want) || line.RequestID == "" {
|
||||||
t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want)
|
t.Errorf("log line\n%+v\nwant\n%+v, with a request id", line.Line, want)
|
||||||
@@ -148,8 +145,7 @@ func wantBareLine(t *testing.T, line logLine) {
|
|||||||
|
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"forwarded_for", "content_type", "content_length", "request_headers",
|
"forwarded_for", "content_type", "content_length", "request_headers",
|
||||||
"has_authorization", "has_cookie", "websocket", "response_content_type",
|
"has_authorization", "has_cookie", "response_content_type", "counts",
|
||||||
"cache_control", "location", "counts",
|
|
||||||
} {
|
} {
|
||||||
_, present := line.fields[name]
|
_, present := line.fields[name]
|
||||||
if present {
|
if present {
|
||||||
@@ -162,8 +158,6 @@ func wantBareLine(t *testing.T, line logLine) {
|
|||||||
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
|
func withTimings(line logLine, want requestlog.Line) requestlog.Line {
|
||||||
want.DurationTotal = line.DurationTotal
|
want.DurationTotal = line.DurationTotal
|
||||||
want.DurationChecks = line.DurationChecks
|
want.DurationChecks = line.DurationChecks
|
||||||
want.DurationUpstreamConnect = line.DurationUpstreamConnect
|
|
||||||
want.DurationUpstreamFirstByte = line.DurationUpstreamFirstByte
|
|
||||||
want.DurationUpstreamTotal = line.DurationUpstreamTotal
|
want.DurationUpstreamTotal = line.DurationUpstreamTotal
|
||||||
|
|
||||||
return want
|
return want
|
||||||
@@ -188,7 +182,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
|||||||
req.Header.Set(tc.header, "secret")
|
req.Header.Set(tc.header, "secret")
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.endedLine(t)
|
||||||
|
|
||||||
_, otherPresent := line.fields[tc.other]
|
_, otherPresent := line.fields[tc.other]
|
||||||
if line.fields[tc.field] != true || otherPresent {
|
if line.fields[tc.field] != true || otherPresent {
|
||||||
@@ -199,81 +193,20 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestIDAndSchemeComeOnlyFromATrustedProxy(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const sentID = "from-traefik"
|
|
||||||
|
|
||||||
sent := http.Header{requestIDHeader: {sentID}, forwardedProto: {secure}}
|
|
||||||
trusted := map[string]string{trustedProxies: trustLocalhost}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
env map[string]string
|
|
||||||
header http.Header
|
|
||||||
// wantID is the request id logged, "" for a new one.
|
|
||||||
wantID, wantScheme string
|
|
||||||
}{
|
|
||||||
{"a trusted proxy's are kept", trusted, sent, sentID, secure},
|
|
||||||
{"without them, the id is new and the scheme http", trusted, nil, "", plain},
|
|
||||||
{"another peer's are replaced", nil, sent, "", plain},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
received := make(chan string, 2)
|
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
received <- r.Header.Get(requestIDHeader)
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, tc.env)
|
|
||||||
|
|
||||||
// Two requests, so that two new ids can be told apart.
|
|
||||||
ids := make([]string, 0, 2)
|
|
||||||
|
|
||||||
for i := range 2 {
|
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
||||||
maps.Copy(req.Header, tc.header)
|
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
|
||||||
|
|
||||||
line := out.requestLines(t, i+1)[i]
|
|
||||||
ids = append(ids, line.RequestID)
|
|
||||||
|
|
||||||
got := <-received
|
|
||||||
if line.RequestID != got || line.Scheme != tc.wantScheme {
|
|
||||||
t.Errorf("log line has request_id %q and scheme %q, and the "+
|
|
||||||
"app received id %q; want the same id and scheme %q",
|
|
||||||
line.RequestID, line.Scheme, got, tc.wantScheme)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case tc.wantID != "" && (ids[0] != tc.wantID || ids[1] != tc.wantID):
|
|
||||||
t.Errorf("request ids %q, want %q", ids, tc.wantID)
|
|
||||||
case tc.wantID == "" && (slices.Contains(ids, sentID) ||
|
|
||||||
slices.Contains(ids, "") || ids[0] == ids[1]):
|
|
||||||
t.Errorf("request ids %q, want two new ones", ids)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTimingsAreInOrder(t *testing.T) {
|
func TestTimingsAreInOrder(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
// The pauses set the times apart; a hold-up of the test only
|
// The pause sets the times apart; a hold-up of the test only lengthens
|
||||||
// lengthens them.
|
// it.
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_ = http.NewResponseController(w).Flush()
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
time.Sleep(time.Millisecond)
|
||||||
|
|
||||||
_, _ = io.WriteString(w, "done")
|
_, _ = io.WriteString(w, "done")
|
||||||
})
|
})
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
|
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
})
|
})
|
||||||
@@ -281,20 +214,19 @@ func TestTimingsAreInOrder(t *testing.T) {
|
|||||||
// Each log line is waited for before the next request, so that the
|
// Each log line is waited for before the next request, so that the
|
||||||
// lines are in order.
|
// lines are in order.
|
||||||
wantStatus(t, get(t, addr, "/"), http.StatusOK)
|
wantStatus(t, get(t, addr, "/"), http.StatusOK)
|
||||||
forwarded := out.requestLines(t, 1)[0]
|
forwarded := out.endedLines(t, 1)[0]
|
||||||
|
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
req.Header.Set(forwardedFor, denied)
|
req.Header.Set(forwardedFor, denied)
|
||||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||||
refused := out.requestLines(t, 2)[1]
|
refused := out.endedLines(t, 2)[1]
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK)
|
wantStatus(t, get(t, server.Addr, discriminator.HealthPath), http.StatusOK)
|
||||||
health := out.requestLines(t, 3)[2]
|
health := out.endedLines(t, 3)[2]
|
||||||
|
|
||||||
// A request passed to the app has every timing; one refused, none of
|
// A request passed to the app has every timing; one refused, none of
|
||||||
// the app's; the health check, which runs no check, only the total.
|
// the app's; the health check, which runs no check, only the total.
|
||||||
wantTimings(t, forwarded, "duration_total", "duration_checks",
|
wantTimings(t, forwarded, "duration_total", "duration_checks",
|
||||||
"duration_upstream_connect", "duration_upstream_first_byte",
|
|
||||||
"duration_upstream_total")
|
"duration_upstream_total")
|
||||||
wantTimings(t, refused, "duration_total", "duration_checks")
|
wantTimings(t, refused, "duration_total", "duration_checks")
|
||||||
wantTimings(t, health, "duration_total")
|
wantTimings(t, health, "duration_total")
|
||||||
@@ -307,17 +239,12 @@ func TestTimingsAreInOrder(t *testing.T) {
|
|||||||
// exact.
|
// exact.
|
||||||
total := microseconds(forwarded.DurationTotal)
|
total := microseconds(forwarded.DurationTotal)
|
||||||
checks := microseconds(*forwarded.DurationChecks)
|
checks := microseconds(*forwarded.DurationChecks)
|
||||||
connect := microseconds(*forwarded.DurationUpstreamConnect)
|
|
||||||
firstByte := microseconds(*forwarded.DurationUpstreamFirstByte)
|
|
||||||
upstream := microseconds(*forwarded.DurationUpstreamTotal)
|
upstream := microseconds(*forwarded.DurationUpstreamTotal)
|
||||||
|
|
||||||
// The checks end before the request is handed to the app, and the
|
// The checks end before the request is handed to the app.
|
||||||
// connection comes before the answer, which the app ends after a
|
if checks+upstream > total {
|
||||||
// pause.
|
t.Errorf("timings in microseconds: total %d, checks %d, upstream total %d",
|
||||||
if checks+upstream > total || connect >= firstByte || firstByte >= upstream {
|
total, checks, upstream)
|
||||||
t.Errorf("timings in microseconds: total %d, checks %d, connect %d, "+
|
|
||||||
"first byte %d, upstream total %d", total, checks, connect, firstByte,
|
|
||||||
upstream)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if *refused.DurationChecks > refused.DurationTotal {
|
if *refused.DurationChecks > refused.DurationTotal {
|
||||||
@@ -346,26 +273,49 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wantHeuristic checks that line names heuristic as the one that refused the
|
||||||
|
// request, with figures, a JSON object. A heuristic of "" with figures "null" is
|
||||||
|
// a line that names none.
|
||||||
|
func wantHeuristic(t *testing.T, line logLine, heuristic, figures string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if line.Heuristic != heuristic ||
|
||||||
|
!reflect.DeepEqual(line.HeuristicFigures, decodeFigures(t, figures)) {
|
||||||
|
t.Errorf("log line has heuristic %q with heuristic_figures %v, want %q with %s",
|
||||||
|
line.Heuristic, line.HeuristicFigures, heuristic, figures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantAfterAnswerHeuristic checks after_answer_heuristic and
|
||||||
|
// after_answer_heuristic_figures as wantHeuristic checks heuristic and
|
||||||
|
// heuristic_figures.
|
||||||
|
func wantAfterAnswerHeuristic(t *testing.T, line logLine, heuristic, figures string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if line.AfterAnswerHeuristic != heuristic ||
|
||||||
|
!reflect.DeepEqual(line.AfterAnswerHeuristicFigures, decodeFigures(t, figures)) {
|
||||||
|
t.Errorf("log line has after_answer_heuristic %q with "+
|
||||||
|
"after_answer_heuristic_figures %v, want %q with %s",
|
||||||
|
line.AfterAnswerHeuristic, line.AfterAnswerHeuristicFigures, heuristic, figures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFigures decodes figures, a JSON object of a heuristic's figures, as
|
||||||
|
// the log line's figures are decoded.
|
||||||
|
func decodeFigures(t *testing.T, figures string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(figures), &decoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", figures, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
// microseconds is a timing in whole microseconds.
|
// microseconds is a timing in whole microseconds.
|
||||||
func microseconds(milliseconds float64) int64 {
|
func microseconds(milliseconds float64) int64 {
|
||||||
return int64(math.Round(milliseconds * 1000))
|
return int64(math.Round(milliseconds * 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogsAnUpgradedConnection(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, echoAfterUpgrade)
|
|
||||||
addr, out := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
||||||
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
||||||
wantStatus(t, readResponse(t, conn), http.StatusSwitchingProtocols)
|
|
||||||
|
|
||||||
_ = conn.Close()
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
if line.fields["websocket"] != true {
|
|
||||||
t.Errorf("log line has websocket %v, want true", line.fields["websocket"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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 ActionRuleBlocked for a block
|
||||||
|
// rule, and ActionBanned for a ban rule. 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.ruleBlocked = true
|
||||||
|
|
||||||
|
return &verdict{action: requestlog.ActionRuleBlocked, figures: figures}
|
||||||
|
case rules.ActionBan:
|
||||||
|
rq.attack = true
|
||||||
|
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
|
||||||
|
|
||||||
|
return &verdict{action: requestlog.ActionBanned, figures: figures}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -34,14 +34,14 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantRuleIDs(t, line, "noted")
|
wantRuleIDs(t, line, "noted")
|
||||||
|
|
||||||
// A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and
|
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
|
||||||
// bans no one.
|
// bans no one.
|
||||||
line = s.request(client, "/blocked", http.StatusForbidden,
|
line = s.request(client, "/blocked", http.StatusForbidden,
|
||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionRuleBlocked)
|
||||||
wantRuleIDs(t, line, "noted", "blocked")
|
wantRuleIDs(t, line, "noted", "blocked")
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for
|
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
|
||||||
// seven days, the default.
|
// seven days, the default.
|
||||||
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
wantRuleIDs(t, line, "noted", "probe")
|
wantRuleIDs(t, line, "noted", "probe")
|
||||||
@@ -93,6 +93,35 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.ActionRuleBlocked), "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.ActionRuleBlocked)
|
||||||
|
wantHeuristic(t, line, "rule_files", `{"rule_id": "blocked", "rule_action": "block"}`)
|
||||||
|
|
||||||
|
line = s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
wantHeuristic(t, line, "rule_files", `{"rule_id": "probe", "rule_action": "ban"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -101,7 +130,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
|||||||
attackBanDuration: "1h",
|
attackBanDuration: "1h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// The first probe bans for SWWAF_ATTACK_BAN_DURATION.
|
// The first probe bans for SWW_ATTACK_BAN_DURATION.
|
||||||
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
||||||
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
||||||
@@ -122,8 +151,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
@@ -133,7 +162,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS is not checked.
|
// A client in SWW_ALLOW_NETS is not checked.
|
||||||
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
@@ -185,8 +214,6 @@ func TestObserveModeLogsWhatTheRulesWouldDo(t *testing.T) {
|
|||||||
func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const scraper = "192.0.2.200"
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
@@ -196,20 +223,20 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
|||||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
metrics := s.scrape(scraper)
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
`sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2)
|
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+
|
wantMetric(t, metrics, `sww_requests_total{action="rule_blocked",`+
|
||||||
`instance="app",status_class="4xx"}`, 1)
|
`instance="app",status_class="4xx"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 1)
|
`sww_offences_total{instance="app",kind="rule_blocked"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="attack"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRules writes content as a rule file into a new directory, and
|
// writeRules writes content as a rule file into a new directory, and
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -17,7 +17,7 @@ const (
|
|||||||
unlistedAddr = "2001:db8::2"
|
unlistedAddr = "2001:db8::2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
|
func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
@@ -26,20 +26,18 @@ func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
|
|||||||
calls.Add(1)
|
calls.Add(1)
|
||||||
})
|
})
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
// fromKP is in SWWAF_ALLOW_NETS, and in SWWAF_DENY_NETS too, which
|
// fromKP is in SWW_ALLOW_NETS, and in SWW_DENY_NETS too, which
|
||||||
// comes after it.
|
// comes after it.
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
allowNets: "198.51.100.0/24",
|
allowNets: "198.51.100.0/24",
|
||||||
denyNets: fromKP,
|
denyNets: fromKP,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
requestMaxBytes: "1K",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one
|
// Neither SWW_DENY_NETS, SWW_TAG_DENY nor the limit of one request a
|
||||||
// request a minute refuses the client, and its country is not looked
|
// minute refuses the client, and its country is not looked up.
|
||||||
// up.
|
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{fromKP, http.StatusOK, requestlog.ActionForward},
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
||||||
{fromKP, http.StatusOK, requestlog.ActionForward},
|
{fromKP, http.StatusOK, requestlog.ActionForward},
|
||||||
@@ -49,14 +47,6 @@ func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
|
|||||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||||
}
|
}
|
||||||
|
|
||||||
// The size limit still applies.
|
|
||||||
body := strings.NewReader(strings.Repeat("a", 2<<10))
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", body)
|
|
||||||
req.Header.Set(forwardedFor, fromKP)
|
|
||||||
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
|
|
||||||
wantLine(t, out.requestLines(t, 3)[2],
|
|
||||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
|
|
||||||
|
|
||||||
if calls.Load() != 2 {
|
if calls.Load() != 2 {
|
||||||
t.Errorf("the app was called %d times, want 2", calls.Load())
|
t.Errorf("the app was called %d times, want 2", calls.Load())
|
||||||
}
|
}
|
||||||
@@ -82,7 +72,7 @@ func TestRequestFromAllowNetsIsNotCounted(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) {
|
func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
@@ -94,20 +84,16 @@ func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) {
|
|||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
denyNets: "203.0.113.0/24",
|
denyNets: "203.0.113.0/24",
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||||
req.Header.Set(forwardedFor, fromDE)
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.endedLine(t)
|
||||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
|
||||||
if line.RequestBytes != 0 {
|
|
||||||
t.Errorf("log line has request_bytes %d, want 0", line.RequestBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(asked()) != 0 {
|
if len(asked()) != 0 {
|
||||||
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
t.Errorf("GeoJS was asked about %v, want nothing", asked())
|
||||||
}
|
}
|
||||||
@@ -117,6 +103,15 @@ func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDenyNetsRefusalNamesItsHeuristicAndNetblock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{denyNets: clientsNet})
|
||||||
|
|
||||||
|
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+clientsNet+`"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
|
func TestRequestRefusedByDenyNetsIsNotCounted(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -146,19 +141,19 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitExemptNets: listedAddr + "," + fromKP,
|
rateLimitExemptNets: listedAddr + "," + fromKP,
|
||||||
deniedCountries: "kp",
|
tagDeny: denyKP,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// listedAddr's requests are neither refused nor counted, so the first
|
// listedAddr's requests are neither refused nor counted, so the first
|
||||||
// request from unlistedAddr is within the limit of one a minute. The
|
// request from unlistedAddr is within the limit of one a minute.
|
||||||
// country lists still refuse an exempt client.
|
// SWW_TAG_DENY still refuses an exempt client.
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
{listedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
|
||||||
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
|
{fromKP, http.StatusForbidden, requestlog.ActionDenied},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,9 +165,9 @@ type sentRequest struct {
|
|||||||
action string
|
action string
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantAnswers sends requests to smallwebwaf at addr one after another and
|
// wantAnswers sends requests to sww at addr one after another and
|
||||||
// checks each one's answer and log line. They must be the first requests
|
// checks each one's answer and log line. They must be the first requests
|
||||||
// smallwebwaf is sent, since the log lines are matched to them in order.
|
// sww is sent, since the log lines are matched to them in order.
|
||||||
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
|
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -180,6 +175,6 @@ func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest)
|
|||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
req.Header.Set(forwardedFor, sent.client)
|
req.Header.Set(forwardedFor, sent.client)
|
||||||
wantStatus(t, do(t, req), sent.status)
|
wantStatus(t, do(t, req), sent.status)
|
||||||
wantLine(t, out.requestLines(t, i+1)[i], sent.status, sent.action)
|
wantLine(t, out.endedLines(t, i+1)[i], sent.status, sent.action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic tag_deny. It reads SWW_TAG_DENY.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
|
||||||
|
// the first such tag, in sorted order.
|
||||||
|
type tagDeny struct{}
|
||||||
|
|
||||||
|
func (tagDeny) name() string { return "tag_deny" }
|
||||||
|
|
||||||
|
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
for _, tag := range rq.line.Tags {
|
||||||
|
if slices.Contains(rq.h.config.TagDeny, tag) {
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionDenied,
|
||||||
|
figures: map[string]any{"tag": tag},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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.ActionDenied)
|
||||||
|
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.ActionDenied),
|
||||||
|
"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.ActionDenied),
|
||||||
|
"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.ActionDenied),
|
||||||
|
"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.ActionDenied)
|
||||||
|
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.ActionRateLimited)
|
||||||
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
|
"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_percent", line.BytesPercent, line.BytesPercentSetting,
|
||||||
|
"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_percent", line.BytesPercent, line.BytesPercentSetting, none)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package discriminator
|
||||||
|
|
||||||
|
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
|
||||||
|
// the reputation lists.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
|
||||||
|
// holds the client, the tag being the list's name. It refuses nothing, and runs
|
||||||
|
// first so that every refusal's log line carries these tags.
|
||||||
|
type tagLists struct{}
|
||||||
|
|
||||||
|
func (tagLists) name() string { return "tag_lists" }
|
||||||
|
|
||||||
|
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
||||||
|
for _, name := range rq.h.lists.TagsOf(rq.client) {
|
||||||
|
rq.addTag(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package discriminator_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
|
||||||
|
const (
|
||||||
|
tagLists = "SWW_TAG_LISTS"
|
||||||
|
vpnList = "https://lists.example/vpn.txt"
|
||||||
|
torList = "https://lists.example/torexit.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wantTags checks the log line's tags.
|
||||||
|
func wantTags(t *testing.T, line logLine, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got, _ := line.fields["tags"].([]any)
|
||||||
|
|
||||||
|
tags := make([]string, 0, len(got))
|
||||||
|
for _, tag := range got {
|
||||||
|
text, _ := tag.(string)
|
||||||
|
tags = append(tags, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(tags, want) {
|
||||||
|
t.Errorf("log line has tags %v, want %v", tags, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{
|
||||||
|
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
|
||||||
|
})
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"asn=64496", "country=DE", "torexit", "vpn=mullvad")
|
||||||
|
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||||
|
"asn=64511", "country=KP", "torexit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// With no lookup, a client is not placed and no setting gives it a tag.
|
||||||
|
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
|
||||||
|
|
||||||
|
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
if _, present := line.fields["tags"]; present {
|
||||||
|
t.Errorf("log line has tags %v, want none", line.fields["tags"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "vpn=" + vpnList, allowedCountries: "de",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
|
||||||
|
|
||||||
|
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||||
|
"asn=64511", "country=KP", "vpn")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, server, _ := startWithLookups(t, map[string]string{
|
||||||
|
tagLists: "vpn=" + vpnList, denyNets: fromKP, rateLimitPerMinute: "1",
|
||||||
|
})
|
||||||
|
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
|
||||||
|
|
||||||
|
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
|
||||||
|
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.ActionRateLimited)
|
||||||
|
|
||||||
|
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
if line.Heuristic != "active_ban" {
|
||||||
|
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantTags(t, line, "vpn")
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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/requestlog"
|
||||||
|
"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
|
||||||
|
rq.banForAttack(now, bans.Notes{TrapPath: path})
|
||||||
|
|
||||||
|
return &verdict{
|
||||||
|
action: requestlog.ActionBanned,
|
||||||
|
figures: map[string]any{"path": path},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package proxy_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,14 +14,14 @@ import (
|
|||||||
// trapPaths is the setting's name, and trapPathList what the tests set it
|
// trapPaths is the setting's name, and trapPathList what the tests set it
|
||||||
// to.
|
// to.
|
||||||
const (
|
const (
|
||||||
trapPaths = "SWWAF_TRAP_PATHS"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
trapPathList = "/wp-login.php,/xmlrpc.php"
|
trapPathList = "/wp-login.php,/xmlrpc.php"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
|
|
||||||
// A block rule for the same path: the trap path comes first.
|
// A block rule for the same path: the trap path comes first.
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
@@ -38,7 +38,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|||||||
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS is not checked.
|
// A client in SWW_ALLOW_NETS is not checked.
|
||||||
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// The query is not part of the path.
|
// The query is not part of the path.
|
||||||
@@ -83,6 +83,16 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.ActionBanned)
|
||||||
|
wantHeuristic(t, line, "trap_paths", `{"path": "/wp-login.php"}`)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
|
func TestTrapPathMatchesThePathDecodedOnce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -104,7 +114,7 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
|||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
trapPaths: trapPathList,
|
trapPaths: trapPathList,
|
||||||
"SWWAF_RULES_ENABLED": "false",
|
"SWW_RULES_ENABLED": "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
+18
-8
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
|
|||||||
// FileParams are what OpenFile needs.
|
// FileParams are what OpenFile needs.
|
||||||
type FileParams struct {
|
type FileParams struct {
|
||||||
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||||
// (SWWAF_LOOKUP_DB_PATH).
|
// (SWW_LOOKUP_DB_PATH).
|
||||||
Path string
|
Path string
|
||||||
// Now tells the time, normally time.Now.
|
// Now tells the time, normally time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -51,7 +51,7 @@ type File struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// record is what the lookup database holds about a network, of the fields
|
// record is what the lookup database holds about a network, of the fields
|
||||||
// smallwebwaf reads.
|
// sww reads.
|
||||||
type record struct {
|
type record struct {
|
||||||
ASN string `maxminddb:"asn"`
|
ASN string `maxminddb:"asn"`
|
||||||
ASName string `maxminddb:"as_name"`
|
ASName string `maxminddb:"as_name"`
|
||||||
@@ -133,8 +133,11 @@ func (f *File) Watch(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
f.params.ProcessLog.Error(
|
||||||
"error", err.Error())
|
"cannot watch the lookup database for replacements",
|
||||||
|
"error",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,7 +197,10 @@ func (f *File) readAgain() {
|
|||||||
f.params.Alerts.Raise(alerts.Alert{
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: kept,
|
Reason: kept,
|
||||||
Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
|
Detail: map[string]any{
|
||||||
|
"file": f.params.Path,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
f.params.ProcessLog.Error(kept, "error", err.Error())
|
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||||
|
|
||||||
@@ -212,7 +218,11 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
f.lastRead = f.params.Now()
|
f.lastRead = f.params.Now()
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
|
f.params.ProcessLog.Info(
|
||||||
|
"read the lookup database",
|
||||||
|
"file",
|
||||||
|
f.params.Path,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// read reads the lookup database at path. The whole file is read into
|
// read reads the lookup database at path. The whole file is read into
|
||||||
@@ -221,12 +231,12 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
func read(path string) (*maxminddb.Reader, error) {
|
func read(path string) (*maxminddb.Reader, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := maxminddb.OpenBytes(data)
|
reader, err := maxminddb.OpenBytes(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return reader, nil
|
return reader, nil
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
|||||||
writeFile(t, notDatabase, "not a lookup database\n")
|
writeFile(t, notDatabase, "not a lookup database\n")
|
||||||
|
|
||||||
for path, want := range map[string]string{
|
for path, want := range map[string]string{
|
||||||
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||||
": no such file or directory",
|
": no such file or directory",
|
||||||
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase +
|
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
|
||||||
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||||
} {
|
} {
|
||||||
_, err := OpenFile(FileParams{
|
_, err := OpenFile(FileParams{
|
||||||
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
|
|||||||
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": path,
|
"file": path,
|
||||||
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||||
"error opening database: invalid MaxMind DB file",
|
"error opening database: invalid MaxMind DB file",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package lookup looks up each client's AS number and country, through
|
// Package lookup looks up each client's AS number and country, through
|
||||||
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
||||||
// SWWAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||||
// most 100,000 clients and for 7 days each, and are written to
|
// most 100,000 clients and for 7 days each, and are written to
|
||||||
// lookups.json and read from it by the state package.
|
// lookups.json and read from it by the state package.
|
||||||
package lookup
|
package lookup
|
||||||
@@ -66,7 +66,7 @@ type Params struct {
|
|||||||
URL string
|
URL string
|
||||||
// Timeout is how long a request waits for its client's first answer,
|
// Timeout is how long a request waits for its client's first answer,
|
||||||
// and how long a request to GeoJS may take before it is abandoned
|
// and how long a request to GeoJS may take before it is abandoned
|
||||||
// (SWWAF_LOOKUP_TIMEOUT).
|
// (SWW_LOOKUP_TIMEOUT).
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Wait is true when a setting needs each request's answer before the
|
// Wait is true when a setting needs each request's answer before the
|
||||||
// request goes on. Otherwise no request waits for one.
|
// request goes on. Otherwise no request waits for one.
|
||||||
@@ -179,7 +179,7 @@ func New(params Params) *GeoJS {
|
|||||||
// own address for IPv4, and an address in the same place for an IPv6
|
// own address for IPv4, and an address in the same place for an IPv6
|
||||||
// netblock.
|
// netblock.
|
||||||
func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
||||||
answer, asked := g.answerOrWait(ctx, client)
|
answer, asked := g.answerOrWait(ctx, client, g.wait)
|
||||||
if asked == nil {
|
if asked == nil {
|
||||||
return answer
|
return answer
|
||||||
}
|
}
|
||||||
@@ -209,6 +209,14 @@ func (g *GeoJS) LookUp(ctx context.Context, client netip.Prefix) Answer {
|
|||||||
return answer
|
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.
|
// Kept returns client's answer, if one is kept, without asking GeoJS.
|
||||||
func (g *GeoJS) Kept(client netip.Prefix) (Answer, bool) {
|
func (g *GeoJS) Kept(client netip.Prefix) (Answer, bool) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
@@ -263,9 +271,9 @@ func (g *GeoJS) Load(answers []Answer) {
|
|||||||
// answerOrWait returns client's kept answer if it has one. Otherwise it
|
// 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
|
// 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
|
// about them if it can be, and returns what to wait on for the answer, or
|
||||||
// nil when there is nothing to wait for.
|
// nil when there is nothing to wait for or mustWait is false.
|
||||||
func (g *GeoJS) answerOrWait(
|
func (g *GeoJS) answerOrWait(
|
||||||
ctx context.Context, client netip.Prefix,
|
ctx context.Context, client netip.Prefix, mustWait bool,
|
||||||
) (Answer, <-chan struct{}) {
|
) (Answer, <-chan struct{}) {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
@@ -283,7 +291,7 @@ func (g *GeoJS) answerOrWait(
|
|||||||
|
|
||||||
g.ask(ctx)
|
g.ask(ctx)
|
||||||
|
|
||||||
if !g.wait {
|
if !mustWait {
|
||||||
return Answer{}, nil // the answer is not needed before the request goes on
|
return Answer{}, nil // the answer is not needed before the request goes on
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,39 @@ func TestKeptAnswerIsUsedFor7DaysThenAskedAgain(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
|
func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -123,8 +156,8 @@ func TestRequestWaitsAsLongAsTheTimeoutSaysAndGeoJSIsAbandonedAfterIt(t *testing
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
// A timeout longer than the default second, and a GeoJS that does
|
// A timeout longer than the default, and a GeoJS that does not
|
||||||
// not answer.
|
// answer.
|
||||||
const longerTimeout = 3 * time.Second
|
const longerTimeout = 3 * time.Second
|
||||||
|
|
||||||
m := metrics.New(1, "app")
|
m := metrics.New(1, "app")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
const fileMode = 0o600
|
const fileMode = 0o600
|
||||||
|
|
||||||
// Network is what a lookup database holds about a netblock, of the fields
|
// Network is what a lookup database holds about a netblock, of the fields
|
||||||
// smallwebwaf reads: its AS number, such as AS64496, the AS's name, and
|
// sww reads: its AS number, such as AS64496, the AS's name, and
|
||||||
// its country, such as DE.
|
// its country, such as DE.
|
||||||
type Network struct {
|
type Network struct {
|
||||||
ASN string
|
ASN string
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type busiest struct {
|
|||||||
requests *prometheus.CounterVec
|
requests *prometheus.CounterVec
|
||||||
requestBytes *prometheus.CounterVec
|
requestBytes *prometheus.CounterVec
|
||||||
responseBytes *prometheus.CounterVec
|
responseBytes *prometheus.CounterVec
|
||||||
// refused are, by country, the requests the country lists refused; nil
|
// refused are, by country, the requests the country list refused; nil
|
||||||
// by AS number.
|
// by AS number.
|
||||||
refused *prometheus.CounterVec
|
refused *prometheus.CounterVec
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ type busiest struct {
|
|||||||
// for the topN busiest countries.
|
// for the topN busiest countries.
|
||||||
func newCountries(topN int) *busiest {
|
func newCountries(topN int) *busiest {
|
||||||
countries := newBusiest(topN, "country", "the client's country")
|
countries := newBusiest(topN, "country", "the client's country")
|
||||||
countries.refused = counterVec("smallwebwaf_country_list_refusals_total",
|
countries.refused = counterVec("sww_country_list_refusals_total",
|
||||||
"Requests the country lists refused, by the client's country.",
|
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
||||||
[]string{"country"})
|
[]string{"country"})
|
||||||
|
|
||||||
return countries
|
return countries
|
||||||
@@ -61,11 +61,11 @@ func newBusiest(topN int, label, description string) *busiest {
|
|||||||
|
|
||||||
return &busiest{
|
return &busiest{
|
||||||
topN: topN,
|
topN: topN,
|
||||||
requests: counterVec("smallwebwaf_"+label+"_requests_total",
|
requests: counterVec("sww_"+label+"_requests_total",
|
||||||
"Requests, by "+description+".", by),
|
"Requests, by "+description+".", by),
|
||||||
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total",
|
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
||||||
"Request body bytes, by "+description+".", by),
|
"Request body bytes, by "+description+".", by),
|
||||||
responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total",
|
responseBytes: counterVec("sww_"+label+"_response_bytes_total",
|
||||||
"Response body bytes, by "+description+".", by),
|
"Response body bytes, by "+description+".", by),
|
||||||
seen: map[string]int64{},
|
seen: map[string]int64{},
|
||||||
top: map[string]bool{},
|
top: map[string]bool{},
|
||||||
@@ -88,7 +88,7 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
||||||
// country, the refusals by the country lists as well.
|
// country, the refusals by the country list as well.
|
||||||
func (b *busiest) vecs() []*prometheus.CounterVec {
|
func (b *busiest) vecs() []*prometheus.CounterVec {
|
||||||
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
||||||
if b.refused != nil {
|
if b.refused != nil {
|
||||||
|
|||||||
+96
-86
@@ -1,6 +1,7 @@
|
|||||||
// Package metrics keeps smallwebwaf's Prometheus metrics, as the "Metrics
|
// Package metrics keeps sww's Prometheus metrics, as
|
||||||
// endpoint" section of SPEC.md lists them, and serves them in the
|
// docs/metrics.md lists them, and serves them in the Prometheus text
|
||||||
// Prometheus text format. No metric carries a client's address.
|
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
||||||
|
// metric carries a client's address.
|
||||||
package metrics
|
package metrics
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -21,7 +22,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
// Metrics are sww's metrics. They are safe for concurrent use.
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
// registry gives every metric registered with it the label instance.
|
// registry gives every metric registered with it the label instance.
|
||||||
registry prometheus.Registerer
|
registry prometheus.Registerer
|
||||||
@@ -34,7 +35,6 @@ type Metrics struct {
|
|||||||
requestDuration prometheus.Histogram
|
requestDuration prometheus.Histogram
|
||||||
upstreamDuration prometheus.Histogram
|
upstreamDuration prometheus.Histogram
|
||||||
rateLimitHits *prometheus.CounterVec
|
rateLimitHits *prometheus.CounterVec
|
||||||
sizeAndTimeLimitHits *prometheus.CounterVec
|
|
||||||
offences *prometheus.CounterVec
|
offences *prometheus.CounterVec
|
||||||
wafMatches *prometheus.CounterVec
|
wafMatches *prometheus.CounterVec
|
||||||
// ruleMatches are made by AddRules, and reputationHits by
|
// ruleMatches are made by AddRules, and reputationHits by
|
||||||
@@ -62,8 +62,8 @@ type Metrics struct {
|
|||||||
|
|
||||||
// New returns the metrics, with the Go runtime's and the process's own.
|
// New returns the metrics, with the Go runtime's and the process's own.
|
||||||
// topN is how many countries and how many AS numbers get series of their
|
// topN is how many countries and how many AS numbers get series of their
|
||||||
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
// own (SWW_METRICS_TOP_N). Every metric carries instanceName
|
||||||
// (SWWAF_INSTANCE_NAME) as its label instance.
|
// (SWW_INSTANCE_NAME) as its label instance.
|
||||||
//
|
//
|
||||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||||
func New(topN int, instanceName string) *Metrics {
|
func New(topN int, instanceName string) *Metrics {
|
||||||
@@ -76,63 +76,60 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
prometheus.Labels{"instance": instanceName}, registry),
|
prometheus.Labels{"instance": instanceName}, registry),
|
||||||
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
||||||
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_requests_in_flight",
|
Name: "sww_requests_in_flight",
|
||||||
Help: "Requests under way.",
|
Help: "Requests under way.",
|
||||||
}),
|
}),
|
||||||
requests: counterVec("smallwebwaf_requests_total",
|
requests: counterVec("sww_requests_total",
|
||||||
"Requests, by the class of their status and their action.", byStatus),
|
"Requests, by the class of their status and their action.", byStatus),
|
||||||
requestBytes: counterVec("smallwebwaf_request_bytes_total",
|
requestBytes: counterVec("sww_request_bytes_total",
|
||||||
"Request body bytes, by the class of the status and the action.",
|
"Request body bytes, by the class of the status and the action.",
|
||||||
byStatus),
|
byStatus),
|
||||||
responseBytes: counterVec("smallwebwaf_response_bytes_total",
|
responseBytes: counterVec("sww_response_bytes_total",
|
||||||
"Response body bytes, by the class of the status and the action.",
|
"Response body bytes, by the class of the status and the action.",
|
||||||
byStatus),
|
byStatus),
|
||||||
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||||
Name: "smallwebwaf_request_duration_seconds",
|
Name: "sww_request_duration_seconds",
|
||||||
Help: "How long requests took, from their arrival to their end.",
|
Help: "How long requests took, from their arrival to their end.",
|
||||||
}),
|
}),
|
||||||
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||||
Name: "smallwebwaf_upstream_duration_seconds",
|
Name: "sww_upstream_duration_seconds",
|
||||||
Help: "How long requests passed to the app took, from then to their end.",
|
Help: "How long requests passed to the app took, from then to their end.",
|
||||||
}),
|
}),
|
||||||
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total",
|
rateLimitHits: counterVec("sww_rate_limit_hits_total",
|
||||||
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
||||||
"its window and its kind, requests, bytes or refusals.",
|
"its window and its kind, requests, bytes or refusals.",
|
||||||
[]string{"window", "kind"}),
|
[]string{"window", "kind"}),
|
||||||
sizeAndTimeLimitHits: counterVec("smallwebwaf_size_and_time_limit_hits_total",
|
offences: counterVec("sww_offences_total",
|
||||||
"Requests that passed a size or time limit, by its setting.",
|
|
||||||
[]string{"limit"}),
|
|
||||||
offences: counterVec("smallwebwaf_offences_total",
|
|
||||||
"Offences, by kind.", []string{"kind"}),
|
"Offences, by kind.", []string{"kind"}),
|
||||||
wafMatches: counterVec("smallwebwaf_waf_matches_total",
|
wafMatches: counterVec("sww_waf_matches_total",
|
||||||
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+
|
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
|
||||||
"and the rule's id.", []string{"mode", "rule_id"}),
|
"and the rule's id.", []string{"mode", "rule_id"}),
|
||||||
countries: newCountries(topN),
|
countries: newCountries(topN),
|
||||||
asns: newASNs(topN),
|
asns: newASNs(topN),
|
||||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_requests_total",
|
Name: "sww_geojs_requests_total",
|
||||||
Help: "Requests to GeoJS.",
|
Help: "Requests to GeoJS.",
|
||||||
}),
|
}),
|
||||||
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_failures_total",
|
Name: "sww_geojs_failures_total",
|
||||||
Help: "Requests to GeoJS that failed.",
|
Help: "Requests to GeoJS that failed.",
|
||||||
}),
|
}),
|
||||||
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_unanswered_total",
|
Name: "sww_geojs_unanswered_total",
|
||||||
Help: "Requests that needed their client's answer from GeoJS and " +
|
Help: "Requests that needed their client's answer from GeoJS and " +
|
||||||
"went on without it, because GeoJS had not given it in time.",
|
"went on without it, because GeoJS had not given it in time.",
|
||||||
}),
|
}),
|
||||||
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
stateFileWrites: counterVec("sww_state_file_writes_total",
|
||||||
"Writes of each state file.", byFile),
|
"Writes of each state file.", byFile),
|
||||||
stateFileWriteFailures: counterVec("smallwebwaf_state_file_write_failures_total",
|
stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
|
||||||
"Writes of each state file that failed.", byFile),
|
"Writes of each state file that failed.", byFile),
|
||||||
stateFileLastWrite: gaugeVec("smallwebwaf_state_file_last_write_timestamp_seconds",
|
stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
|
||||||
"When each state file was last written, in seconds since 1970.", byFile),
|
"When each state file was last written, in seconds since 1970.", byFile),
|
||||||
stateFileSize: gaugeVec("smallwebwaf_state_file_size_bytes",
|
stateFileSize: gaugeVec("sww_state_file_size_bytes",
|
||||||
"The size of each state file, as it was last written.", byFile),
|
"The size of each state file, as it was last written.", byFile),
|
||||||
stateFileEditsTakenIn: counterVec("smallwebwaf_state_file_edits_taken_in_total",
|
stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
|
||||||
"Edits of each state file taken in while running.", byFile),
|
"Edits of each state file taken in while running.", byFile),
|
||||||
stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total",
|
stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
|
||||||
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
||||||
byFile),
|
byFile),
|
||||||
}
|
}
|
||||||
@@ -142,7 +139,7 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||||
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
m.inFlight, m.requests, m.requestBytes, m.responseBytes,
|
||||||
m.requestDuration, m.upstreamDuration,
|
m.requestDuration, m.upstreamDuration,
|
||||||
m.rateLimitHits, m.sizeAndTimeLimitHits, m.offences, m.wafMatches,
|
m.rateLimitHits, m.offences, m.wafMatches,
|
||||||
m.countries, m.asns,
|
m.countries, m.asns,
|
||||||
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
m.GeoJSRequests, m.GeoJSFailures, m.GeoJSUnanswered,
|
||||||
m.stateFileWrites, m.stateFileWriteFailures,
|
m.stateFileWrites, m.stateFileWriteFailures,
|
||||||
@@ -164,7 +161,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
||||||
} {
|
} {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_bans_made_total",
|
Name: "sww_bans_made_total",
|
||||||
Help: "Bans made, by cause.",
|
Help: "Bans made, by cause.",
|
||||||
ConstLabels: prometheus.Labels{"cause": cause},
|
ConstLabels: prometheus.Labels{"cause": cause},
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
@@ -174,7 +171,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_active_bans",
|
Name: "sww_active_bans",
|
||||||
Help: "Bans active now, the permanent ones included.",
|
Help: "Bans active now, the permanent ones included.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
active, _ := ledger.Count(now())
|
active, _ := ledger.Count(now())
|
||||||
@@ -182,7 +179,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
return float64(active)
|
return float64(active)
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_permanent_bans",
|
Name: "sww_permanent_bans",
|
||||||
Help: "Permanent bans not lifted.",
|
Help: "Permanent bans not lifted.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
_, permanent := ledger.Count(now())
|
_, permanent := ledger.Count(now())
|
||||||
@@ -190,7 +187,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
return float64(permanent)
|
return float64(permanent)
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_tracked_clients",
|
Name: "sww_tracked_clients",
|
||||||
Help: "Clients in the table of clients.",
|
Help: "Clients in the table of clients.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(limiter.Len())
|
return float64(limiter.Len())
|
||||||
@@ -203,13 +200,13 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
// ruleFiles, read as the metrics are asked for. It is called once, before
|
// ruleFiles, read as the metrics are asked for. It is called once, before
|
||||||
// RuleMatched.
|
// RuleMatched.
|
||||||
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
||||||
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total",
|
m.ruleMatches = counterVec("sww_rule_matches_total",
|
||||||
"Requests that matched a rule of the rule files, by its id and action.",
|
"Requests that matched a rule of the rule files, by its id and action.",
|
||||||
[]string{"rule_id", "action"})
|
[]string{"rule_id", "action"})
|
||||||
|
|
||||||
m.registry.MustRegister(m.ruleMatches,
|
m.registry.MustRegister(m.ruleMatches,
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_rules_loaded",
|
Name: "sww_rules_loaded",
|
||||||
Help: "Rules loaded from the rule files.",
|
Help: "Rules loaded from the rule files.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(ruleFiles.Len())
|
return float64(ruleFiles.Len())
|
||||||
@@ -217,25 +214,25 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddRemoteLog adds the metrics of sending the log lines to
|
// AddRemoteLog adds the metrics of sending the log lines to
|
||||||
// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||||
// lines sent, those dropped, and those waiting in the buffer.
|
// lines sent, those dropped, and those waiting in the buffer.
|
||||||
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_remote_log_lines_sent_total",
|
Name: "sww_remote_log_lines_sent_total",
|
||||||
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.",
|
Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Sent())
|
return float64(remote.Sent())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_remote_log_lines_dropped_total",
|
Name: "sww_remote_log_lines_dropped_total",
|
||||||
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
||||||
"whose sending failed.",
|
"whose sending failed.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Dropped())
|
return float64(remote.Dropped())
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_remote_log_buffer_depth",
|
Name: "sww_remote_log_buffer_depth",
|
||||||
Help: "Log lines in the buffer, waiting to be sent.",
|
Help: "Log lines in the buffer, waiting to be sent.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Depth())
|
return float64(remote.Depth())
|
||||||
@@ -251,13 +248,13 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
|||||||
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_lookup_database_last_read_timestamp_seconds",
|
Name: "sww_lookup_database_last_read_timestamp_seconds",
|
||||||
Help: "When the lookup database in use was read, in seconds since 1970.",
|
Help: "When the lookup database in use was read, in seconds since 1970.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(lastRead().Unix())
|
return float64(lastRead().Unix())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_lookup_database_read_failures_total",
|
Name: "sww_lookup_database_read_failures_total",
|
||||||
Help: "Replacements of the lookup database that could not be read.",
|
Help: "Replacements of the lookup database that could not be read.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(readFailures())
|
return float64(readFailures())
|
||||||
@@ -278,7 +275,7 @@ const sourceLabel = "source"
|
|||||||
// copy in use was fetched, and for a zone, the queries made and those that
|
// copy in use was fetched, and for a zone, the queries made and those that
|
||||||
// failed. It is called once, before ReputationHit.
|
// failed. It is called once, before ReputationHit.
|
||||||
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
||||||
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
|
m.reputationHits = counterVec("sww_reputation_hits_total",
|
||||||
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
||||||
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
||||||
[]string{sourceLabel})
|
[]string{sourceLabel})
|
||||||
@@ -297,7 +294,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
Name: "sww_reputation_last_fetch_timestamp_seconds",
|
||||||
Help: "When the copy of the list in use was fetched, in seconds since " +
|
Help: "When the copy of the list in use was fetched, in seconds since " +
|
||||||
"1970, or 0 while there is none.",
|
"1970, or 0 while there is none.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
@@ -316,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
||||||
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
||||||
// that failed, and how many checks the day's budget has left. It is
|
// that failed, and how many checks the day's budget has left. It is
|
||||||
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set.
|
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
|
||||||
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||||
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
||||||
|
|
||||||
@@ -324,8 +321,8 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
|||||||
m.addReputationFailures(source, abuseIPDB.Failures)
|
m.addReputationFailures(source, abuseIPDB.Failures)
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_reputation_daily_budget_remaining",
|
Name: "sww_reputation_daily_budget_remaining",
|
||||||
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(abuseIPDB.BudgetLeft())
|
return float64(abuseIPDB.BudgetLeft())
|
||||||
@@ -351,29 +348,29 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
|
|||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_sent_total",
|
Name: "sww_alerts_sent_total",
|
||||||
Help: "Alerts the destination took.",
|
Help: "Alerts the destination took.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Counts(name).Sent)
|
return float64(queue.Counts(name).Sent)
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_failed_total",
|
Name: "sww_alerts_failed_total",
|
||||||
Help: "Requests to the destination that failed.",
|
Help: "Requests to the destination that failed.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Counts(name).Failed)
|
return float64(queue.Counts(name).Failed)
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_suppressed_total",
|
Name: "sww_alerts_suppressed_total",
|
||||||
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " +
|
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
|
||||||
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Suppressed())
|
return float64(queue.Suppressed())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_dropped_total",
|
Name: "sww_alerts_dropped_total",
|
||||||
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
||||||
"alerts given up as the destination refused them.",
|
"alerts given up as the destination refused them.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
@@ -394,36 +391,26 @@ func (m *Metrics) RequestStarted() {
|
|||||||
m.inFlight.Inc()
|
m.inFlight.Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestEnded counts a request that has ended, from its log line. limit
|
// RequestEnded counts a request that has ended without being passed to the app,
|
||||||
// is the setting whose size or time limit the request passed, "" if none.
|
// from its log line. duration is how long the request took.
|
||||||
// duration is how long the request took, and upstreamDuration how long it
|
func (m *Metrics) RequestEnded(line *requestlog.Line, duration time.Duration) {
|
||||||
// took from when it was passed to the app, zero if it was not.
|
|
||||||
func (m *Metrics) RequestEnded(
|
|
||||||
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration,
|
|
||||||
) {
|
|
||||||
m.inFlight.Dec()
|
m.inFlight.Dec()
|
||||||
|
m.count(line, duration, 0)
|
||||||
|
}
|
||||||
|
|
||||||
class := statusClass(line.Status)
|
// RequestAllowed counts a question of rproxy's, answered allow, as no longer
|
||||||
m.requests.WithLabelValues(class, line.Action).Inc()
|
// under way. RequestReported counts the request it was about.
|
||||||
m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes))
|
func (m *Metrics) RequestAllowed() {
|
||||||
m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes))
|
m.inFlight.Dec()
|
||||||
m.requestDuration.Observe(duration.Seconds())
|
}
|
||||||
|
|
||||||
if upstreamDuration > 0 {
|
// RequestReported counts a request rproxy passed to the app, from what its
|
||||||
m.upstreamDuration.Observe(upstreamDuration.Seconds())
|
// report says, which line holds: duration is how long the request took, and
|
||||||
}
|
// upstreamDuration how long it took from when it was passed to the app.
|
||||||
|
func (m *Metrics) RequestReported(
|
||||||
if limit != "" {
|
line *requestlog.Line, duration, upstreamDuration time.Duration,
|
||||||
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
|
) {
|
||||||
}
|
m.count(line, duration, upstreamDuration)
|
||||||
|
|
||||||
if line.Country != "" {
|
|
||||||
m.countries.add(line.Country, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
if line.ASN != "" {
|
|
||||||
m.asns.add(line.ASN, line)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LimitHit counts a request that broke a rate limit, a byte limit or the
|
// LimitHit counts a request that broke a rate limit, a byte limit or the
|
||||||
@@ -441,7 +428,6 @@ func (m *Metrics) Offences(r ratelimit.Request) {
|
|||||||
"attack": r.Attack,
|
"attack": r.Attack,
|
||||||
"rule_blocked": r.RuleBlocked,
|
"rule_blocked": r.RuleBlocked,
|
||||||
"waf_blocked": r.WAFBlocked,
|
"waf_blocked": r.WAFBlocked,
|
||||||
"token_refused": r.TokenRefused,
|
|
||||||
} {
|
} {
|
||||||
if committed {
|
if committed {
|
||||||
m.offences.WithLabelValues(kind).Inc()
|
m.offences.WithLabelValues(kind).Inc()
|
||||||
@@ -456,7 +442,7 @@ func (m *Metrics) RuleMatched(id, action string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
||||||
// with SWWAF_WAF_MODE at mode.
|
// with SWW_WAF_MODE at mode.
|
||||||
func (m *Metrics) WAFMatched(mode string, id int) {
|
func (m *Metrics) WAFMatched(mode string, id int) {
|
||||||
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||||
}
|
}
|
||||||
@@ -481,7 +467,7 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StateFileEditTakenIn counts an admin's edit of the state file name
|
// StateFileEditTakenIn counts an admin's edit of the state file name
|
||||||
// taken in while smallwebwaf runs.
|
// taken in while sww runs.
|
||||||
func (m *Metrics) StateFileEditTakenIn(name string) {
|
func (m *Metrics) StateFileEditTakenIn(name string) {
|
||||||
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
||||||
}
|
}
|
||||||
@@ -492,11 +478,35 @@ func (m *Metrics) StateFileEditSetAside(name string) {
|
|||||||
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
|
m.stateFileEditsSetAside.WithLabelValues(name).Inc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// count counts a request that has ended, as RequestEnded and RequestReported
|
||||||
|
// describe.
|
||||||
|
func (m *Metrics) count(
|
||||||
|
line *requestlog.Line, duration, upstreamDuration time.Duration,
|
||||||
|
) {
|
||||||
|
class := statusClass(line.Status)
|
||||||
|
m.requests.WithLabelValues(class, line.Action).Inc()
|
||||||
|
m.requestBytes.WithLabelValues(class, line.Action).Add(float64(line.RequestBytes))
|
||||||
|
m.responseBytes.WithLabelValues(class, line.Action).Add(float64(line.ResponseBytes))
|
||||||
|
m.requestDuration.Observe(duration.Seconds())
|
||||||
|
|
||||||
|
if upstreamDuration > 0 {
|
||||||
|
m.upstreamDuration.Observe(upstreamDuration.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.Country != "" {
|
||||||
|
m.countries.add(line.Country, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.ASN != "" {
|
||||||
|
m.asns.add(line.ASN, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// addReputationQueries adds the counter of the queries to source, a DNSBL
|
// addReputationQueries adds the counter of the queries to source, a DNSBL
|
||||||
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
||||||
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_reputation_queries_total",
|
Name: "sww_reputation_queries_total",
|
||||||
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
@@ -509,7 +519,7 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in
|
|||||||
// that failed, which count tells.
|
// that failed, which count tells.
|
||||||
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_reputation_failures_total",
|
Name: "sww_reputation_failures_total",
|
||||||
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
||||||
"AbuseIPDB, that failed.",
|
"AbuseIPDB, that failed.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
package proxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// 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 smallwebwaf serves.
|
|
||||||
secure = "https"
|
|
||||||
plain = "http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// appHeaders is what the app tells about the headers it received.
|
|
||||||
type appHeaders struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
ForwardedFor string `json:"forwardedFor"`
|
|
||||||
ForwardedHost string `json:"forwardedHost"`
|
|
||||||
ForwardedProto string `json:"forwardedProto"`
|
|
||||||
RealIP string `json:"realIp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientAddressCase is a request and what smallwebwaf makes of it.
|
|
||||||
type clientAddressCase struct {
|
|
||||||
name string
|
|
||||||
env map[string]string
|
|
||||||
header http.Header
|
|
||||||
wantClient string
|
|
||||||
wantApp appHeaders
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientAddressAndForwardedHeaders(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range clientAddressCases() {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
got, line := requestWithHeaders(t, tc.env, tc.header)
|
|
||||||
|
|
||||||
tc.wantApp.Host = appHost
|
|
||||||
if got != tc.wantApp {
|
|
||||||
t.Errorf("app received %+v, want %+v", got, tc.wantApp)
|
|
||||||
}
|
|
||||||
|
|
||||||
if line.ClientIP != tc.wantClient || line.PeerIP != localhost {
|
|
||||||
t.Errorf("log line has client_ip %q and peer_ip %q, want %q and %q",
|
|
||||||
line.ClientIP, line.PeerIP, tc.wantClient, localhost)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientAddressCases are the requests TestClientAddressAndForwardedHeaders
|
|
||||||
// sends, from 127.0.0.1, which the default trusted proxies leave out.
|
|
||||||
func clientAddressCases() []clientAddressCase {
|
|
||||||
trusted := map[string]string{trustedProxies: trustLocalhost}
|
|
||||||
forged := http.Header{
|
|
||||||
forwardedFor: {client},
|
|
||||||
"X-Forwarded-Host": {"forged.example"},
|
|
||||||
forwardedProto: {secure},
|
|
||||||
"X-Real-Ip": {client},
|
|
||||||
}
|
|
||||||
replaced := appHeaders{
|
|
||||||
ForwardedFor: localhost, ForwardedHost: appHost, ForwardedProto: plain,
|
|
||||||
}
|
|
||||||
|
|
||||||
return []clientAddressCase{{
|
|
||||||
name: "a peer outside the trusted proxies is the client, " +
|
|
||||||
"and its forwarded headers are replaced",
|
|
||||||
header: forged, wantClient: localhost, wantApp: replaced,
|
|
||||||
}, {
|
|
||||||
name: "set but empty, the trusted proxies trust nothing",
|
|
||||||
env: map[string]string{trustedProxies: ""},
|
|
||||||
header: forged, wantClient: localhost, wantApp: replaced,
|
|
||||||
}, {
|
|
||||||
name: "behind a trusted peer, the client is the first address " +
|
|
||||||
"outside the trusted proxies from the right",
|
|
||||||
env: trusted,
|
|
||||||
header: http.Header{
|
|
||||||
forwardedFor: {"198.51.100.7, " + client + ", 10.0.0.2"},
|
|
||||||
"X-Forwarded-Host": {appHost},
|
|
||||||
forwardedProto: {secure},
|
|
||||||
"X-Real-Ip": {client},
|
|
||||||
},
|
|
||||||
wantClient: client,
|
|
||||||
wantApp: appHeaders{
|
|
||||||
ForwardedFor: "198.51.100.7, " + client + ", 10.0.0.2, " + localhost,
|
|
||||||
ForwardedHost: appHost, ForwardedProto: secure, RealIP: client,
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
name: "when every address is a trusted proxy, the leftmost is the client",
|
|
||||||
env: trusted,
|
|
||||||
header: http.Header{forwardedFor: {"10.0.0.5, 10.0.0.2"}},
|
|
||||||
wantClient: "10.0.0.5",
|
|
||||||
wantApp: appHeaders{ForwardedFor: "10.0.0.5, 10.0.0.2, " + localhost},
|
|
||||||
}, {
|
|
||||||
name: "with no header, a trusted peer is the client",
|
|
||||||
env: trusted,
|
|
||||||
wantClient: localhost,
|
|
||||||
wantApp: appHeaders{ForwardedFor: localhost},
|
|
||||||
}, {
|
|
||||||
name: "an entry that is not an address ends the reading",
|
|
||||||
env: trusted,
|
|
||||||
header: http.Header{forwardedFor: {client + ", unknown, 10.0.0.2"}},
|
|
||||||
wantClient: "10.0.0.2",
|
|
||||||
wantApp: appHeaders{
|
|
||||||
ForwardedFor: client + ", unknown, 10.0.0.2, " + localhost,
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
name: "several header lines are read as one list",
|
|
||||||
env: trusted,
|
|
||||||
header: http.Header{forwardedFor: {"2001:db8::7", "10.0.0.2"}},
|
|
||||||
wantClient: "2001:db8::7",
|
|
||||||
wantApp: appHeaders{ForwardedFor: "2001:db8::7, 10.0.0.2, " + localhost},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestWithHeaders sends a request for appHost with header through
|
|
||||||
// smallwebwaf, with the settings in env, and returns the headers the app
|
|
||||||
// received and the request's log line.
|
|
||||||
func requestWithHeaders(
|
|
||||||
t *testing.T, env map[string]string, header http.Header,
|
|
||||||
) (appHeaders, logLine) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_ = json.NewEncoder(w).Encode(appHeaders{
|
|
||||||
Host: r.Host,
|
|
||||||
ForwardedFor: r.Header.Get(forwardedFor),
|
|
||||||
ForwardedHost: r.Header.Get("X-Forwarded-Host"),
|
|
||||||
ForwardedProto: r.Header.Get(forwardedProto),
|
|
||||||
RealIP: r.Header.Get("X-Real-IP"),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, env)
|
|
||||||
|
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
||||||
req.Host = appHost
|
|
||||||
req.Header = header.Clone()
|
|
||||||
|
|
||||||
answered := do(t, req)
|
|
||||||
|
|
||||||
var got appHeaders
|
|
||||||
|
|
||||||
err := json.Unmarshal(answered.body, &got)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("decode the app's answer %q: %v", answered.body, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return got, out.requestLine(t)
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
|
||||||
)
|
|
||||||
|
|
||||||
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
|
|
||||||
// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as
|
|
||||||
// pathExempt decides, and notes the rules it matched and its score in the
|
|
||||||
// log line, and the rules in the metrics. A score at or over
|
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
|
||||||
// and in block mode refuses the request, which is an offence its client's
|
|
||||||
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
|
||||||
// request it does not refuse, and for one whose body meets a size or time
|
|
||||||
// limit while the Core Rule Set reads it, which it notes nothing of.
|
|
||||||
func (rq *request) checkCoreRuleSet() string {
|
|
||||||
cfg := rq.h.config
|
|
||||||
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
result := rq.inspect()
|
|
||||||
if rq.refused.Load() != nil {
|
|
||||||
return "" // the refusal for that limit, which check returns
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
|
|
||||||
rq.line.WAFRuleIDs = result.RuleIDs
|
|
||||||
rq.line.WAFScore = &result.Score
|
|
||||||
|
|
||||||
for _, id := range result.RuleIDs {
|
|
||||||
rq.h.metrics.WAFMatched(cfg.WAFMode, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
threshold := cfg.WAFAnomalyThreshold
|
|
||||||
if threshold == 0 || result.Score < threshold {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.alertWAFBlock(result)
|
|
||||||
|
|
||||||
if cfg.WAFMode == config.WAFModeDetect {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.wafBlocked = true
|
|
||||||
|
|
||||||
return requestlog.ActionWAFBlocked
|
|
||||||
}
|
|
||||||
|
|
||||||
// inspect runs the Core Rule Set on the request, which reads the part of
|
|
||||||
// its body it inspects within SWWAF_CLIENT_REQUEST_TIMEOUT, and keeps that
|
|
||||||
// part for the app. A client that runs out of time is refused with 408
|
|
||||||
// here, and a body over SWWAF_REQUEST_MAX_BYTES with 413 as it is read;
|
|
||||||
// check returns the refusal. A body that breaks off for any other reason
|
|
||||||
// is passed on as far as it came, and the request to the app fails there,
|
|
||||||
// as it would have without the Core Rule Set.
|
|
||||||
func (rq *request) inspect() waf.Result {
|
|
||||||
if rq.body == nil {
|
|
||||||
// Nothing is read of no body, so nothing can go wrong reading it.
|
|
||||||
result, _, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, http.NoBody)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = rq.rc.SetReadDeadline(rq.clientRequestDeadline())
|
|
||||||
result, read, err := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.body)
|
|
||||||
// The timeouts that run while the request goes to the app take over.
|
|
||||||
_ = rq.rc.SetReadDeadline(time.Time{})
|
|
||||||
|
|
||||||
rq.body.readByCoreRuleSet = read
|
|
||||||
|
|
||||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
|
||||||
rq.refuse(refusal{
|
|
||||||
status: http.StatusRequestTimeout,
|
|
||||||
action: requestlog.ActionTimedOut,
|
|
||||||
limit: "SWWAF_CLIENT_REQUEST_TIMEOUT",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
|
||||||
// Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its
|
|
||||||
// detail gives the rule ids, the score, the method and the path with the
|
|
||||||
// query, and, for a request that is not refused for it, the mode: detect,
|
|
||||||
// or observe in observe mode.
|
|
||||||
func (rq *request) alertWAFBlock(result waf.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.EventWAFBlock,
|
|
||||||
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 SWWAF_WAF_ANOMALY_THRESHOLD",
|
|
||||||
Detail: detail,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
)
|
|
||||||
|
|
||||||
// countryDenied reports whether the country lists refuse the request, by
|
|
||||||
// the client's country as it was looked up. A client without a country,
|
|
||||||
// or whose country cannot be found, is refused only by
|
|
||||||
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
|
||||||
func (rq *request) countryDenied() bool {
|
|
||||||
denied := rq.h.config.DeniedCountries
|
|
||||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
|
||||||
country := rq.line.Country
|
|
||||||
|
|
||||||
if slices.Contains(denied, country) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(allowed) > 0 && !slices.Contains(allowed, country)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package proxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHealthEndpointIsAnsweredBeforeAnyCheck(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var calls atomic.Int32
|
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
})
|
|
||||||
// With a limit of one request a minute, any request counted before
|
|
||||||
// the last one would have it refused.
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{rateLimitPerMinute: "1"})
|
|
||||||
|
|
||||||
const (
|
|
||||||
healthChecks = 3
|
|
||||||
contentType = "text/plain; charset=utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
for range healthChecks {
|
|
||||||
got := get(t, addr, proxy.HealthPath)
|
|
||||||
wantStatus(t, got, 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, "/"), http.StatusOK)
|
|
||||||
|
|
||||||
lines := out.requestLines(t, healthChecks+1)
|
|
||||||
for _, line := range lines[:healthChecks] {
|
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionAdmin)
|
|
||||||
|
|
||||||
if line.ResponseContentType != contentType {
|
|
||||||
t.Errorf("health check's log line has response_content_type %q, "+
|
|
||||||
"want %q", line.ResponseContentType, contentType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wantLine(t, lines[healthChecks], http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
if calls.Load() != 1 {
|
|
||||||
t.Errorf("the app was called %d times, want once", calls.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package proxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sizeLimit is the size limit the tests set, 1K as a setting.
|
|
||||||
const (
|
|
||||||
sizeLimit = 1 << 10
|
|
||||||
sizeLimitSetting = "1K"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRequestBodyLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
size int
|
|
||||||
// announced sends the size in Content-Length; otherwise the body
|
|
||||||
// is sent in chunks with no length given.
|
|
||||||
announced bool
|
|
||||||
want int
|
|
||||||
action string
|
|
||||||
// refusedBeforeApp is a refusal before anything reaches the app.
|
|
||||||
// A body over the limit with no length given has already partly
|
|
||||||
// reached the app when it is refused.
|
|
||||||
refusedBeforeApp bool
|
|
||||||
}{
|
|
||||||
{"announced, over the limit", 2 * sizeLimit, true,
|
|
||||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge, true},
|
|
||||||
{"announced, at the limit", sizeLimit, true,
|
|
||||||
http.StatusOK, requestlog.ActionForward, false},
|
|
||||||
{"not announced, over the limit", 4 * sizeLimit, false,
|
|
||||||
http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge, false},
|
|
||||||
{"not announced, at the limit", sizeLimit, false,
|
|
||||||
http.StatusOK, requestlog.ActionForward, false},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var calls atomic.Int32
|
|
||||||
|
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
|
|
||||||
_, _ = io.Copy(io.Discard, r.Body)
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
requestMaxBytes: sizeLimitSetting,
|
|
||||||
metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
var body io.Reader = bytes.NewReader(make([]byte, tc.size))
|
|
||||||
if !tc.announced {
|
|
||||||
body = io.MultiReader(body) // hides the length
|
|
||||||
}
|
|
||||||
|
|
||||||
wantStatus(t, do(t, newRequest(t, http.MethodPost, addr, "/upload", body)),
|
|
||||||
tc.want)
|
|
||||||
wantLine(t, out.requestLine(t), tc.want, tc.action)
|
|
||||||
|
|
||||||
hits := 0
|
|
||||||
if tc.action == requestlog.ActionTooLarge {
|
|
||||||
hits = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
wantLimitHits(t, addr, requestMaxBytes, hits)
|
|
||||||
|
|
||||||
if tc.refusedBeforeApp && calls.Load() != 0 {
|
|
||||||
t.Errorf("the app was called %d times, want never", calls.Load())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponseBodyLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
size int
|
|
||||||
// announced sends the size in Content-Length; otherwise the body
|
|
||||||
// is sent in chunks with no length given.
|
|
||||||
announced bool
|
|
||||||
want int
|
|
||||||
action string
|
|
||||||
// received is how much of a body the client gets, and cutOff
|
|
||||||
// whether the connection is then cut.
|
|
||||||
received int
|
|
||||||
cutOff bool
|
|
||||||
}{
|
|
||||||
{"announced, over the limit", 2 * sizeLimit, true, http.StatusBadGateway,
|
|
||||||
requestlog.ActionTooLarge, len("Bad Gateway\n"), false},
|
|
||||||
{"announced, at the limit", sizeLimit, true, http.StatusOK,
|
|
||||||
requestlog.ActionForward, sizeLimit, false},
|
|
||||||
{"not announced, over the limit", 4 * sizeLimit, false, http.StatusOK,
|
|
||||||
requestlog.ActionTooLarge, sizeLimit, true},
|
|
||||||
{"not announced, at the limit", sizeLimit, false, http.StatusOK,
|
|
||||||
requestlog.ActionForward, sizeLimit, false},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
answerWithSize(w, tc.size, tc.announced)
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
responseMaxBytes: sizeLimitSetting,
|
|
||||||
metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
got := get(t, addr, "/download")
|
|
||||||
wantStatus(t, got, tc.want)
|
|
||||||
|
|
||||||
if len(got.body) != tc.received ||
|
|
||||||
errors.Is(got.err, io.ErrUnexpectedEOF) != tc.cutOff {
|
|
||||||
t.Errorf("client got %d bytes (%v), want %d",
|
|
||||||
len(got.body), got.err, tc.received)
|
|
||||||
}
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantLine(t, line, tc.want, tc.action)
|
|
||||||
|
|
||||||
if line.UpstreamStatus != http.StatusOK {
|
|
||||||
t.Errorf("log line has upstream_status %d", line.UpstreamStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
hits := 0
|
|
||||||
if tc.action == requestlog.ActionTooLarge {
|
|
||||||
hits = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
wantLimitHits(t, addr, responseMaxBytes, hits)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// answerWithSize answers with a body of size bytes, announced in
|
|
||||||
// Content-Length or sent in chunks with no length given.
|
|
||||||
func answerWithSize(w http.ResponseWriter, size int, announced bool) {
|
|
||||||
body := make([]byte, size)
|
|
||||||
if announced {
|
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(size))
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sending part of it before the end keeps Go's server from working
|
|
||||||
// out the length.
|
|
||||||
_, _ = w.Write(body[:size/2])
|
|
||||||
_ = http.NewResponseController(w).Flush()
|
|
||||||
_, _ = w.Write(body[size/2:])
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The headers in which the app is passed the client's AS number and
|
|
||||||
// country while SWWAF_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, and Del removes a client's own whatever their
|
|
||||||
// case; 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, unless
|
|
||||||
// SWWAF_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.
|
|
||||||
// ctx is the request's own context.
|
|
||||||
func (rq *request) lookUp(ctx context.Context) {
|
|
||||||
if rq.h.config.LookupSource == "off" || !canBePlaced(rq.client) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.h.config.LookupSource == "file" {
|
|
||||||
rq.lookupAnswer = rq.h.lookupFile.LookUp(rq.h.clientGroup(rq.client))
|
|
||||||
} else {
|
|
||||||
rq.lookupAnswer = rq.h.geojs.LookUp(ctx, rq.h.clientGroup(rq.client))
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.lookedUp = true
|
|
||||||
rq.line.ASN = rq.lookupAnswer.ASN
|
|
||||||
rq.line.ASName = rq.lookupAnswer.ASName
|
|
||||||
rq.line.Country = rq.lookupAnswer.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 in which the app is passed 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,545 +0,0 @@
|
|||||||
package proxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
|
||||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
|
||||||
// token is the SWWAF_METRICS_TOKEN the tests set, and bearer how a
|
|
||||||
// request carries it.
|
|
||||||
token = "0123456789abcdef0123456789abcdef"
|
|
||||||
bearer = "Bearer " + token
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMetricsAreOffWhileTheTokenIsUnset(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, nil)
|
|
||||||
|
|
||||||
// An empty token does not match the unset one either.
|
|
||||||
for i, authorization := range []string{bearer, "Bearer ", ""} {
|
|
||||||
req := newRequest(t, http.MethodGet, addr, proxy.MetricsPath, http.NoBody)
|
|
||||||
if authorization != "" {
|
|
||||||
req.Header.Set("Authorization", authorization)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantStatus(t, do(t, req), http.StatusNotFound)
|
|
||||||
wantLine(t, out.requestLines(t, i+1)[i], http.StatusNotFound,
|
|
||||||
requestlog.ActionAdmin)
|
|
||||||
}
|
|
||||||
|
|
||||||
if calls.Load() != 0 {
|
|
||||||
t.Errorf("the app was called %d times, want never", calls.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(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{metricsToken: token})
|
|
||||||
|
|
||||||
for i, tc := range []struct {
|
|
||||||
method, path, authorization string
|
|
||||||
status int
|
|
||||||
}{
|
|
||||||
{http.MethodGet, proxy.MetricsPath, "", http.StatusUnauthorized},
|
|
||||||
{
|
|
||||||
http.MethodGet, proxy.MetricsPath, "Bearer " + strings.ToUpper(token),
|
|
||||||
http.StatusUnauthorized,
|
|
||||||
},
|
|
||||||
{http.MethodGet, proxy.MetricsPath, "Basic " + token, http.StatusUnauthorized},
|
|
||||||
{http.MethodGet, proxy.MetricsPath, bearer, http.StatusOK},
|
|
||||||
{http.MethodGet, proxy.MetricsPath, "bearer " + token, http.StatusOK},
|
|
||||||
{http.MethodPost, proxy.MetricsPath, bearer, http.StatusNotFound},
|
|
||||||
{http.MethodGet, proxy.MetricsPath + "/", bearer, http.StatusNotFound},
|
|
||||||
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound},
|
|
||||||
{http.MethodPost, proxy.HealthPath, "", http.StatusNotFound},
|
|
||||||
} {
|
|
||||||
req := newRequest(t, tc.method, addr, tc.path, http.NoBody)
|
|
||||||
if tc.authorization != "" {
|
|
||||||
req.Header.Set("Authorization", tc.authorization)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := do(t, req)
|
|
||||||
wantStatus(t, got, tc.status)
|
|
||||||
wantLine(t, out.requestLines(t, i+1)[i], tc.status, requestlog.ActionAdmin)
|
|
||||||
|
|
||||||
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 smallwebwaf_requests_total counter") {
|
|
||||||
t.Errorf("the metrics are\n%s", got.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if calls.Load() != 0 {
|
|
||||||
t.Errorf("the app was called %d times, want never", calls.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetricsAreAskedForThroughTheChecks(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
metricsToken: token,
|
|
||||||
rateLimitPerMinute: "1",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Asking for the metrics counts toward the client's limit of one
|
|
||||||
// request a minute, so its next request breaks it, and bans it. A
|
|
||||||
// banned client is refused the metrics too.
|
|
||||||
s.scrape(client)
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
|
||||||
s.requestWithHeader(client, proxy.MetricsPath, "Authorization: "+bearer,
|
|
||||||
http.StatusForbidden, requestlog.ActionBanned)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetricsCountTheTraffic(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
arrived, release := make(chan struct{}), make(chan struct{})
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = io.Copy(io.Discard, r.Body)
|
|
||||||
|
|
||||||
if r.URL.Path == "/held" {
|
|
||||||
close(arrived)
|
|
||||||
<-release
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = io.WriteString(w, "hello")
|
|
||||||
})
|
|
||||||
releaseApp := sync.OnceFunc(func() { close(release) })
|
|
||||||
t.Cleanup(releaseApp)
|
|
||||||
|
|
||||||
addr, out := startProxy(t, app.URL, 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, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
|
||||||
out.requestLines(t, 2)
|
|
||||||
|
|
||||||
forward := `{action="forward",instance="app",status_class="2xx"}`
|
|
||||||
notFound := `{action="admin",instance="app",status_class="4xx"}`
|
|
||||||
|
|
||||||
// The request for the metrics is itself under way.
|
|
||||||
metrics := scrape(t, addr)
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1)
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1)
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3)
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
|
|
||||||
float64(len("Not Found\n")))
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_request_duration_seconds_count{instance="app"}`, 2)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
|
||||||
metric(t, metrics, `go_goroutines{instance="app"}`)
|
|
||||||
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
|
||||||
|
|
||||||
// A request the app holds is under way until it ends.
|
|
||||||
httpClient := newClient(t)
|
|
||||||
held := newRequest(t, http.MethodGet, addr, "/held", http.NoBody)
|
|
||||||
ended := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
res, err := httpClient.Do(held)
|
|
||||||
if err == nil {
|
|
||||||
err = readAnswer(res).err
|
|
||||||
}
|
|
||||||
|
|
||||||
ended <- err
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-arrived
|
|
||||||
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 2)
|
|
||||||
releaseApp()
|
|
||||||
|
|
||||||
err := <-ended
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("held request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out.requestLines(t, 5)
|
|
||||||
wantMetric(t, scrape(t, addr), `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
scraper = "192.0.2.200" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
|
||||||
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
|
||||||
)
|
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
|
||||||
metricsToken: token,
|
|
||||||
rateLimitPerMinute: "1",
|
|
||||||
rateLimitExemptNets: scraper,
|
|
||||||
denyNets: denied,
|
|
||||||
banResponse: "close",
|
|
||||||
limitBanDuration: "1h",
|
|
||||||
maxBanDuration: "2h",
|
|
||||||
})
|
|
||||||
|
|
||||||
// SWWAF_BAN_RESPONSE=close sends no status at all.
|
|
||||||
s.get(denied, 0, requestlog.ActionDenied)
|
|
||||||
|
|
||||||
// A first broken limit bans for an hour.
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
s.get(client, 0, requestlog.ActionRateLimited)
|
|
||||||
|
|
||||||
metrics := s.scrape(scraper)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
|
||||||
`kind="requests",window="minute"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0)
|
|
||||||
|
|
||||||
clk.advance(time.Hour)
|
|
||||||
wantMetric(t, s.scrape(scraper), `smallwebwaf_active_bans{instance="app"}`, 0)
|
|
||||||
|
|
||||||
// A limit broken again right after would ban for three hours, longer
|
|
||||||
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
s.get(client, 0, requestlog.ActionRateLimited)
|
|
||||||
|
|
||||||
metrics = s.scrape(scraper)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
|
||||||
`kind="requests",window="minute"}`, 2)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
|
||||||
// denied, client, and the scraper as of its earlier requests.
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const scraper = "192.0.2.200" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
|
||||||
|
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
|
||||||
metricsToken: token,
|
|
||||||
adminToken: adminSecret,
|
|
||||||
rateLimitExemptNets: scraper,
|
|
||||||
})
|
|
||||||
|
|
||||||
const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}`
|
|
||||||
|
|
||||||
wantMetric(t, s.scrape(scraper), 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(scraper), admins, 1)
|
|
||||||
|
|
||||||
// And a ban made through the endpoint.
|
|
||||||
s.admin(http.MethodPost, proxy.BansPath, banOtherClient, http.StatusOK)
|
|
||||||
wantMetric(t, s.scrape(scraper), 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",
|
|
||||||
deniedCountries: "kp",
|
|
||||||
}
|
|
||||||
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.requestLines(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, addr)
|
|
||||||
lines++
|
|
||||||
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 1)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_list_refusals_total{country="KP",instance="app"}`, 3)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_request_bytes_total{country="KP",instance="app"}`, 0)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`, 6)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_response_bytes_total{country="KP",instance="app"}`,
|
|
||||||
float64(3*len("Forbidden\n")))
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_response_bytes_total{country="other",instance="app"}`,
|
|
||||||
float64(len("hello")))
|
|
||||||
wantNoSeries(t, metrics,
|
|
||||||
`smallwebwaf_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, addr)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 2)
|
|
||||||
wantNoSeries(t, metrics,
|
|
||||||
`smallwebwaf_country_requests_total{country="DE",instance="app"}`)
|
|
||||||
wantNoSeries(t, metrics,
|
|
||||||
`smallwebwaf_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. The
|
|
||||||
// metrics are asked for from a private address, which has no AS number.
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
s.get(fromKP, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
metrics := s.scrape("10.0.0.9")
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_asn_requests_total{asn="other",instance="app"}`, 1)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
|
|
||||||
wantMetric(t, metrics,
|
|
||||||
`smallwebwaf_asn_response_bytes_total{asn="other",instance="app"}`, 0)
|
|
||||||
wantNoSeries(t, metrics,
|
|
||||||
`smallwebwaf_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, _ := startProxyWithGeoJS(t, app.URL, geojs.URL, map[string]string{
|
|
||||||
trustedProxies: trustLocalhost,
|
|
||||||
metricsToken: token,
|
|
||||||
deniedCountries: "kp",
|
|
||||||
})
|
|
||||||
|
|
||||||
// GeoJS fails, so the client counts as coming from an unknown country,
|
|
||||||
// which SWWAF_DENIED_COUNTRIES does not refuse.
|
|
||||||
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 after a second, so GeoJS's
|
|
||||||
// failure can come after its request has ended.
|
|
||||||
deadline := time.Now().Add(waitLimit)
|
|
||||||
metrics := scrape(t, addr)
|
|
||||||
|
|
||||||
for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 &&
|
|
||||||
time.Now().Before(deadline) {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
|
|
||||||
metrics = scrape(t, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1)
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_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 smallwebwaf 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, proxy.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, from the client at from,
|
|
||||||
// and returns them.
|
|
||||||
func (s *sender) scrape(from string) string {
|
|
||||||
s.t.Helper()
|
|
||||||
|
|
||||||
_, metrics := s.requestWithHeader(from, proxy.MetricsPath, "Authorization: "+bearer,
|
|
||||||
http.StatusOK, requestlog.ActionAdmin)
|
|
||||||
|
|
||||||
return metrics
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// smallwebwaf_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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantLimitHits checks that the metrics of smallwebwaf at addr count hits
|
|
||||||
// requests that passed the size or time limit of the setting limit, with
|
|
||||||
// no series for it when hits is 0.
|
|
||||||
func wantLimitHits(t *testing.T, addr, limit string, hits int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
series := `smallwebwaf_size_and_time_limit_hits_total{instance="app",limit="` +
|
|
||||||
limit + `"}`
|
|
||||||
metrics := scrape(t, addr)
|
|
||||||
|
|
||||||
if hits == 0 {
|
|
||||||
wantNoSeries(t, metrics, series)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wantMetric(t, metrics, series, float64(hits))
|
|
||||||
}
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
package proxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A request target with an escaped slash and space in its path, and a
|
|
||||||
// query with a parameter ReverseProxy cannot parse.
|
|
||||||
const (
|
|
||||||
rawPath = "/some%2Fpath/with%20space"
|
|
||||||
rawQuery = "b=2&a=1&bad=%zz;x"
|
|
||||||
)
|
|
||||||
|
|
||||||
// chunkSize is the size of each part of a body a test sends in parts.
|
|
||||||
const chunkSize = 1 << 10
|
|
||||||
|
|
||||||
var errNotStreamed = errors.New("the first part never reached the app")
|
|
||||||
|
|
||||||
// appSaw is what the app received.
|
|
||||||
type appSaw struct {
|
|
||||||
method string
|
|
||||||
target string
|
|
||||||
header http.Header
|
|
||||||
body []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPassesRequestAndAnswerUnchanged(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
requestBody := bytes.Repeat([]byte("request body "), 8000)
|
|
||||||
answerBody := bytes.Repeat([]byte("answer body "), 8000)
|
|
||||||
saw := make(chan appSaw, 1)
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
saw <- appSaw{r.Method, r.RequestURI, r.Header.Clone(), body}
|
|
||||||
|
|
||||||
w.Header().Set("X-App", "yes")
|
|
||||||
w.Header().Add("Set-Cookie", "a=1")
|
|
||||||
w.Header().Add("Set-Cookie", "b=2")
|
|
||||||
w.WriteHeader(http.StatusTeapot)
|
|
||||||
_, _ = w.Write(answerBody)
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPatch, addr, rawPath+"?"+rawQuery,
|
|
||||||
bytes.NewReader(requestBody))
|
|
||||||
req.Header.Add("X-Test", "one")
|
|
||||||
req.Header.Add("X-Test", "two")
|
|
||||||
req.Header.Set("User-Agent", "test-agent")
|
|
||||||
|
|
||||||
got := do(t, req)
|
|
||||||
|
|
||||||
wantAppSaw(t, <-saw, requestBody)
|
|
||||||
wantAnswer(t, got, answerBody)
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantLine(t, line, http.StatusTeapot, requestlog.ActionForward)
|
|
||||||
wantRequestFields(t, line, addr, len(requestBody), len(answerBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPassesThePathAsSent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, path := range []string{
|
|
||||||
// An encoded slash and a character Go escapes: |, ^, and é sent
|
|
||||||
// as its UTF-8 bytes.
|
|
||||||
"/a%2Fb|c", "/feat%2Fx/file^1", "/x%2Fy/café",
|
|
||||||
// A path starting with //, which Go would send as a scheme and
|
|
||||||
// host if it were put in URL.Opaque.
|
|
||||||
"//a%2Fb",
|
|
||||||
} {
|
|
||||||
t.Run(path, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
saw := make(chan string, 1)
|
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
saw <- r.RequestURI
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "GET "+path+"?q=1 HTTP/1.1\r\nHost: "+appHost+"\r\n\r\n")
|
|
||||||
|
|
||||||
got := readResponse(t, conn)
|
|
||||||
if got.status != http.StatusOK {
|
|
||||||
t.Fatalf("status %d, want %d", got.status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
target := <-saw
|
|
||||||
if target != path+"?q=1" {
|
|
||||||
t.Errorf("app saw %s, want %s", target, path+"?q=1")
|
|
||||||
}
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
if line.Path != path {
|
|
||||||
t.Errorf("log line has path %s, want %s", line.Path, path)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantAppSaw checks that the app received the test's request unchanged.
|
|
||||||
func wantAppSaw(t *testing.T, saw appSaw, body []byte) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if saw.method != http.MethodPatch || saw.target != rawPath+"?"+rawQuery {
|
|
||||||
t.Errorf("app saw %s %s, want %s %s", saw.method, saw.target,
|
|
||||||
http.MethodPatch, rawPath+"?"+rawQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Equal(saw.header.Values("X-Test"), []string{"one", "two"}) {
|
|
||||||
t.Errorf("app saw X-Test %q", saw.header.Values("X-Test"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if saw.header.Get("User-Agent") != "test-agent" {
|
|
||||||
t.Errorf("app saw User-Agent %q", saw.header.Get("User-Agent"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !bytes.Equal(saw.body, body) {
|
|
||||||
t.Errorf("app saw a body of %d bytes, want the %d sent",
|
|
||||||
len(saw.body), len(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantAnswer checks that the client received the app's answer unchanged.
|
|
||||||
func wantAnswer(t *testing.T, got answer, body []byte) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wantStatus(t, got, http.StatusTeapot)
|
|
||||||
|
|
||||||
if got.header.Get("X-App") != "yes" {
|
|
||||||
t.Errorf("client got X-App %q", got.header.Get("X-App"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Equal(got.header.Values("Set-Cookie"), []string{"a=1", "b=2"}) {
|
|
||||||
t.Errorf("client got Set-Cookie %q", got.header.Values("Set-Cookie"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.err != nil || !bytes.Equal(got.body, body) {
|
|
||||||
t.Errorf("client got %d bytes (%v), want the %d the app sent",
|
|
||||||
len(got.body), got.err, len(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantRequestFields checks the log line's fields about the request. Its
|
|
||||||
// time, its id and its timings are checked only for being there.
|
|
||||||
func wantRequestFields(t *testing.T, line logLine, host string, sent, received int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
bytes := float64(sent + received)
|
|
||||||
|
|
||||||
want := withTimings(line, requestlog.Line{
|
|
||||||
Type: requestType, Time: line.Time, Instance: "app",
|
|
||||||
ClientIP: localhost, Method: http.MethodPatch, Scheme: plain, Host: host,
|
|
||||||
Path: rawPath, Query: rawQuery, Protocol: protocol,
|
|
||||||
Status: http.StatusTeapot, RequestBytes: int64(sent),
|
|
||||||
ResponseBytes: int64(received), UserAgent: "test-agent",
|
|
||||||
RequestID: line.RequestID, PeerIP: localhost, ClientGroup: localhost + "/32",
|
|
||||||
ContentLength: int64(sent), ResponseContentType: "text/plain; charset=utf-8",
|
|
||||||
UpstreamStatus: http.StatusTeapot, Action: requestlog.ActionForward,
|
|
||||||
Counts: ratelimit.Counts{
|
|
||||||
Minute: 1, Hour: 1, Day: 1,
|
|
||||||
MinuteBytes: bytes, HourBytes: bytes, DayBytes: bytes,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if !reflect.DeepEqual(line.Line, want) {
|
|
||||||
t.Errorf("log line\n%+v\nwant\n%+v", line.Line, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := time.Parse(time.RFC3339, line.Time)
|
|
||||||
if err != nil || line.RequestID == "" || line.DurationTotal <= 0 ||
|
|
||||||
line.DurationUpstreamTotal == nil || *line.DurationUpstreamTotal <= 0 {
|
|
||||||
t.Errorf("log line has time %q, request_id %q and durations %v and %v",
|
|
||||||
line.Time, line.RequestID, line.DurationTotal,
|
|
||||||
line.fields["duration_upstream_total"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddsNoContentTypeTheAppDidNotSend(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const typed = "text/plain"
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/typed" {
|
|
||||||
w.Header().Set("Content-Type", typed)
|
|
||||||
} else {
|
|
||||||
// The key with no value keeps Go's server from adding one.
|
|
||||||
w.Header()["Content-Type"] = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
|
|
||||||
})
|
|
||||||
addr, _ := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
got := get(t, addr, "/")
|
|
||||||
if got.header.Values("Content-Type") != nil {
|
|
||||||
t.Errorf("client got Content-Type %q, want none",
|
|
||||||
got.header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
got = get(t, addr, "/typed")
|
|
||||||
if got.header.Get("Content-Type") != typed {
|
|
||||||
t.Errorf("client got Content-Type %q, want %q",
|
|
||||||
got.header.Get("Content-Type"), typed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStreamsTheRequestBody(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
chunk := bytes.Repeat([]byte("x"), chunkSize)
|
|
||||||
firstArrived := make(chan struct{})
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
first := make([]byte, len(chunk))
|
|
||||||
|
|
||||||
_, err := io.ReadFull(r.Body, first)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
close(firstArrived)
|
|
||||||
|
|
||||||
rest, _ := io.ReadAll(r.Body)
|
|
||||||
_, _ = w.Write(rest)
|
|
||||||
})
|
|
||||||
addr, _ := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
body, writer := io.Pipe()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
_, _ = writer.Write(chunk)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-firstArrived:
|
|
||||||
_, _ = writer.Write(chunk)
|
|
||||||
_ = writer.Close()
|
|
||||||
case <-time.After(waitLimit):
|
|
||||||
_ = writer.CloseWithError(errNotStreamed)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
got := do(t, newRequest(t, http.MethodPost, addr, "/upload", body))
|
|
||||||
if got.err != nil || !bytes.Equal(got.body, chunk) {
|
|
||||||
t.Errorf("app read %d bytes after the first part (%v), want %d",
|
|
||||||
len(got.body), got.err, len(chunk))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStreamsTheAnswerBody(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
chunk := bytes.Repeat([]byte("y"), chunkSize)
|
|
||||||
firstArrived := make(chan struct{})
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write(chunk)
|
|
||||||
_ = http.NewResponseController(w).Flush()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-firstArrived:
|
|
||||||
_, _ = w.Write(chunk)
|
|
||||||
case <-time.After(waitLimit):
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addr, _ := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
req := newRequest(t, http.MethodGet, addr, "/download", http.NoBody)
|
|
||||||
|
|
||||||
res, err := newClient(t).Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
first := make([]byte, len(chunk))
|
|
||||||
_, err = io.ReadFull(res.Body, first)
|
|
||||||
|
|
||||||
close(firstArrived)
|
|
||||||
|
|
||||||
got := readAnswer(res)
|
|
||||||
if err != nil || got.err != nil || !bytes.Equal(got.body, chunk) {
|
|
||||||
t.Errorf("client read %d bytes after the first part (%v, %v), want %d",
|
|
||||||
len(got.body), err, got.err, len(chunk))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpgradedConnectionOutlastsTheTimeouts(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, echoAfterUpgrade)
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
clientRequestTimeout: shortTimeoutSetting,
|
|
||||||
clientResponseTimeout: shortTimeoutSetting,
|
|
||||||
upstreamRequestTimeout: shortTimeoutSetting,
|
|
||||||
upstreamResponseTimeout: shortTimeoutSetting,
|
|
||||||
})
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
||||||
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
||||||
|
|
||||||
reader := bufio.NewReader(conn)
|
|
||||||
|
|
||||||
res, err := http.ReadResponse(reader, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read the answer to the upgrade: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
answered := time.Now()
|
|
||||||
_ = res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode != http.StatusSwitchingProtocols {
|
|
||||||
t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every timeout started before the upgrade was answered, the response
|
|
||||||
// timeouts last, at the end of the request: wait until just past
|
|
||||||
// shortTimeout after the answer was read, then use the connection.
|
|
||||||
time.Sleep(time.Until(answered.Add(shortTimeout + 100*time.Millisecond)))
|
|
||||||
send(t, conn, "still here\n")
|
|
||||||
|
|
||||||
echoed, err := reader.ReadString('\n')
|
|
||||||
if err != nil || echoed != "still here\n" {
|
|
||||||
t.Errorf("echo %q (%v), want %q", echoed, err, "still here\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = conn.Close()
|
|
||||||
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusSwitchingProtocols,
|
|
||||||
requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
// echoAfterUpgrade is an app that switches protocols on request, and then
|
|
||||||
// sends back each line it receives.
|
|
||||||
func echoAfterUpgrade(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Header.Get("Upgrade") != "websocket" {
|
|
||||||
http.Error(w, "not an upgrade", http.StatusBadRequest)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
line, err := buffered.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = buffered.WriteString(line)
|
|
||||||
_ = buffered.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnswers502WhenTheAppSwitchesToAnotherProtocol(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// answerAfterUpgrade switches to websocket whatever the client asked
|
|
||||||
// for, and ReverseProxy passes on a switch only to the protocol the
|
|
||||||
// client asked for, here "websocket, foo".
|
|
||||||
app := startApp(t, answerAfterUpgrade)
|
|
||||||
addr, out := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "GET /ws HTTP/1.1\r\nHost: app\r\n"+
|
|
||||||
"Connection: Upgrade\r\nUpgrade: websocket, foo\r\n\r\n")
|
|
||||||
|
|
||||||
wantStatus(t, readResponse(t, conn), http.StatusBadGateway)
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusBadGateway, requestlog.ActionUpstreamError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerHasTheDefaultLimits(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg, err := config.FromEnvironment(func(string) (string, bool) { return "", false })
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("default settings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
server := proxy.New(proxy.Params{
|
|
||||||
Config: cfg,
|
|
||||||
RequestLog: io.Discard,
|
|
||||||
ProcessLog: requestlog.NewProcessLogger(io.Discard, cfg.InstanceName, cfg.LogLevel),
|
|
||||||
})
|
|
||||||
|
|
||||||
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
|
||||||
server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute {
|
|
||||||
t.Errorf("server listens on %q with header limit %d, idle time %s and "+
|
|
||||||
"header timeout %s", server.Addr, server.MaxHeaderBytes,
|
|
||||||
server.IdleTimeout, server.ReadHeaderTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefusesHeadersOverTheLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
env map[string]string
|
|
||||||
limit int
|
|
||||||
}{
|
|
||||||
{"by default", nil, 32 << 10},
|
|
||||||
{"as set", map[string]string{clientHeaderMaxBytes: "8K"}, 8 << 10},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var calls atomic.Int32
|
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
})
|
|
||||||
addr, _ := startProxy(t, app.URL, tc.env)
|
|
||||||
|
|
||||||
// size counts every byte of the request: the request line,
|
|
||||||
// the headers and the blank line that ends them.
|
|
||||||
const (
|
|
||||||
start = "GET / HTTP/1.1\r\nHost: app\r\nX-Large: "
|
|
||||||
end = "\r\n\r\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, sent := range []struct{ size, want int }{
|
|
||||||
{tc.limit, http.StatusOK},
|
|
||||||
{tc.limit + 1, http.StatusRequestHeaderFieldsTooLarge},
|
|
||||||
} {
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn,
|
|
||||||
start+strings.Repeat("a", sent.size-len(start)-len(end))+end)
|
|
||||||
wantStatus(t, readResponse(t, conn), sent.want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if calls.Load() != 1 {
|
|
||||||
t.Errorf("the app was called %d times, want once", calls.Load())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnswers502WhenTheAppCannotBeReached(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// No test can listen on port 1: listening on port 0 gets one from 32768 up.
|
|
||||||
addr, out := startProxy(t, "http://"+localhost+":1", nil)
|
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantLine(t, line, http.StatusBadGateway, requestlog.ActionUpstreamError)
|
|
||||||
|
|
||||||
// There never was a connection to the app, nor an answer from it.
|
|
||||||
wantTimings(t, line, "duration_total", "duration_checks",
|
|
||||||
"duration_upstream_total")
|
|
||||||
|
|
||||||
logged := slices.ContainsFunc(out.lines(t), func(line map[string]any) bool {
|
|
||||||
return line["type"] == "process" && line["msg"] == "request to the app failed"
|
|
||||||
})
|
|
||||||
if !logged {
|
|
||||||
t.Errorf("no process line says the request to the app failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogLevelHoldsBackNoRequestLine(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// At error the warning that the request to the app failed is held back,
|
|
||||||
// and is written before the answer is.
|
|
||||||
addr, out := startProxy(t, "http://"+localhost+":1", map[string]string{
|
|
||||||
"SWWAF_LOG_LEVEL": "error",
|
|
||||||
})
|
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, "/"), http.StatusBadGateway)
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusBadGateway, requestlog.ActionUpstreamError)
|
|
||||||
|
|
||||||
for _, line := range out.lines(t) {
|
|
||||||
if line["type"] == "process" {
|
|
||||||
t.Errorf("process line %v, want none at error", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogsAnAnswerThatBrokeOff(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = io.WriteString(w, "the first part")
|
|
||||||
_ = http.NewResponseController(w).Flush()
|
|
||||||
|
|
||||||
panic(http.ErrAbortHandler) // drops the connection mid-answer
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
got := get(t, addr, "/")
|
|
||||||
if string(got.body) != "the first part" || !errors.Is(got.err, io.ErrUnexpectedEOF) {
|
|
||||||
t.Errorf("client read %q (%v), want the first part cut off", got.body, got.err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionUpstreamError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogsAClientThatWentAway(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
arrived := make(chan struct{})
|
|
||||||
|
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
close(arrived)
|
|
||||||
<-r.Context().Done()
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "GET /slow HTTP/1.1\r\nHost: app\r\n\r\n")
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-arrived:
|
|
||||||
case <-time.After(waitLimit):
|
|
||||||
t.Fatal("the request never reached the app")
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = conn.Close()
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
if !line.Aborted || line.Status != 0 || line.Action != requestlog.ActionForward {
|
|
||||||
t.Errorf("log line has aborted %v, status %d and action %q, "+
|
|
||||||
"want true, 0 and %q",
|
|
||||||
line.Aborted, line.Status, line.Action, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
// Package proxy passes each request to the app and the app's answer back,
|
|
||||||
// unchanged, within the size and time limits, and writes one request log
|
|
||||||
// line for each request.
|
|
||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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/lookup"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/metrics"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
|
||||||
)
|
|
||||||
|
|
||||||
// How smallwebwaf keeps connections to the app open between requests.
|
|
||||||
const (
|
|
||||||
appIdleConns = 100
|
|
||||||
appIdleConnTimeout = 90 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// adminPrefix starts the path of every request for smallwebwaf itself,
|
|
||||||
// which never reaches the app.
|
|
||||||
const adminPrefix = "/_smallwebwaf/"
|
|
||||||
|
|
||||||
// HealthPath is smallwebwaf's health endpoint, which the container's
|
|
||||||
// health check asks.
|
|
||||||
const HealthPath = "/_smallwebwaf/healthz"
|
|
||||||
|
|
||||||
// MetricsPath is where the metrics are, for a request that carries
|
|
||||||
// SWWAF_METRICS_TOKEN.
|
|
||||||
const MetricsPath = "/_smallwebwaf/metrics"
|
|
||||||
|
|
||||||
// BansPath is where an admin lists and adds bans, and, followed by / and
|
|
||||||
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN.
|
|
||||||
const BansPath = "/_smallwebwaf/bans"
|
|
||||||
|
|
||||||
// ClientsPath is where an admin asks what smallwebwaf knows of a client,
|
|
||||||
// by the client's address after it, with SWWAF_ADMIN_TOKEN.
|
|
||||||
const ClientsPath = "/_smallwebwaf/clients/"
|
|
||||||
|
|
||||||
// Params are what New needs.
|
|
||||||
type Params struct {
|
|
||||||
Config *config.Config
|
|
||||||
// RequestLog receives one JSON line per request.
|
|
||||||
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 SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
|
||||||
GeoJSURL string
|
|
||||||
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
|
|
||||||
// SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
|
|
||||||
AbuseIPDBURL string
|
|
||||||
// LookupFile is the lookup database they are looked up in while
|
|
||||||
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
|
|
||||||
LookupFile *lookup.File
|
|
||||||
// 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 the proxy 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
|
|
||||||
// SWWAF_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 smallwebwaf runs, with the parts of the proxy
|
|
||||||
// whose state the state files keep, the lookup database, nil unless
|
|
||||||
// SWWAF_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.
|
|
||||||
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
|
|
||||||
Metrics *metrics.Metrics
|
|
||||||
}
|
|
||||||
|
|
||||||
// New returns the server smallwebwaf runs: each request it reads passes
|
|
||||||
// through the proxy. Go's server itself refuses a request line and
|
|
||||||
// headers over SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES, with 431, closes a
|
|
||||||
// connection idle for SWWAF_CLIENT_IDLE_TIMEOUT, and applies
|
|
||||||
// SWWAF_CLIENT_REQUEST_TIMEOUT while the headers arrive; the proxy
|
|
||||||
// applies the timeouts and size limits from then on.
|
|
||||||
func New(params Params) *Server {
|
|
||||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
|
||||||
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
|
|
||||||
lists, dnsbl, abuseIPDB := newReputation(params, m)
|
|
||||||
h := &handler{
|
|
||||||
config: params.Config,
|
|
||||||
requestLog: params.RequestLog,
|
|
||||||
processLog: params.ProcessLog,
|
|
||||||
errorLog: errorLog,
|
|
||||||
transport: newTransport(),
|
|
||||||
now: params.Now,
|
|
||||||
metrics: m,
|
|
||||||
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.Rules{
|
|
||||||
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: newCoreRuleSet(params.Config),
|
|
||||||
alerts: params.Alerts,
|
|
||||||
}
|
|
||||||
h.geojs = lookup.New(lookup.Params{
|
|
||||||
URL: params.GeoJSURL,
|
|
||||||
Timeout: params.Config.LookupTimeout,
|
|
||||||
// The country lists, the headers and the biased thresholds act on
|
|
||||||
// the answer before the request goes on.
|
|
||||||
Wait: len(params.Config.DeniedCountries) > 0 ||
|
|
||||||
len(params.Config.ExclusivelyAllowedCountries) > 0 ||
|
|
||||||
params.Config.AddLookupHeaders || 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 &Server{
|
|
||||||
Server: &http.Server{
|
|
||||||
Addr: params.Config.ListenAddr,
|
|
||||||
Handler: h,
|
|
||||||
ReadHeaderTimeout: params.Config.ClientRequestTimeout,
|
|
||||||
// Off is an IdleTimeout of 0, which Go's server replaces with
|
|
||||||
// ReadTimeout: no limit, as long as ReadTimeout stays unset.
|
|
||||||
IdleTimeout: params.Config.ClientIdleTimeout,
|
|
||||||
// Go's server reads 4 KiB past MaxHeaderBytes before it
|
|
||||||
// refuses, so the limit a client meets is the setting.
|
|
||||||
MaxHeaderBytes: int(params.Config.ClientRequestHeaderMaxBytes - 4<<10),
|
|
||||||
ErrorLog: errorLog,
|
|
||||||
},
|
|
||||||
Ledger: h.ledger,
|
|
||||||
Limiter: h.limiter,
|
|
||||||
GeoJS: h.geojs,
|
|
||||||
Anomalies: h.anomalies,
|
|
||||||
LookupFile: h.lookupFile,
|
|
||||||
Lists: h.lists,
|
|
||||||
DNSBL: h.dnsbl,
|
|
||||||
AbuseIPDB: h.abuseIPDB,
|
|
||||||
Metrics: m,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 SWWAF_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,
|
|
||||||
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 SWWAF_WAF_PARANOIA_LEVEL,
|
|
||||||
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
|
|
||||||
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off.
|
|
||||||
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
|
|
||||||
if cfg.WAFMode == config.WAFModeOff {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
coreRuleSet, err := waf.New(waf.Params{
|
|
||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
|
||||||
BodyLimit: cfg.WAFBodyLimit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// The Core Rule Set is built in, and the settings cannot break it:
|
|
||||||
// the paranoia level is from 1 to 4, the body limit at most 1G, and
|
|
||||||
// the id of no rule switches nothing off.
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return coreRuleSet
|
|
||||||
}
|
|
||||||
|
|
||||||
// handler is the proxy. 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
|
|
||||||
errorLog *log.Logger
|
|
||||||
transport http.RoundTripper
|
|
||||||
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
|
|
||||||
rules *rules.Files
|
|
||||||
coreRuleSet *waf.CoreRuleSet
|
|
||||||
alerts *alerts.Queue
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTransport returns what carries requests to the app. It never goes
|
|
||||||
// through a proxy named in the environment, and leaves the app's answers
|
|
||||||
// compressed or not as the app sent them.
|
|
||||||
func newTransport() *http.Transport {
|
|
||||||
return &http.Transport{
|
|
||||||
MaxIdleConns: appIdleConns,
|
|
||||||
MaxIdleConnsPerHost: appIdleConns,
|
|
||||||
IdleConnTimeout: appIdleConnTimeout,
|
|
||||||
DisableCompression: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP handles one request: it works out the client, runs the
|
|
||||||
// checks, passes the request to the app and the answer back within the
|
|
||||||
// limits, or answers it itself if it is for smallwebwaf, and writes the
|
|
||||||
// request's log line.
|
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
rq := h.newRequest(w, r)
|
|
||||||
defer rq.finish()
|
|
||||||
|
|
||||||
// The health endpoint is answered at once, before any check, so that
|
|
||||||
// a health checker is never refused. It does not ask the app.
|
|
||||||
if r.Method == http.MethodGet && r.URL.Path == HealthPath {
|
|
||||||
rq.line.Action = requestlog.ActionAdmin
|
|
||||||
// Set here rather than left to Go's server, which would set it only
|
|
||||||
// after the log line has taken the response's headers.
|
|
||||||
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
_, _ = io.WriteString(rq.out, "ok\n")
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the request has ended, before its log line is written. The
|
|
||||||
// last deferred runs first: countRefusal before addToHistory, so that
|
|
||||||
// a broken error burst is in the client's history.
|
|
||||||
defer rq.addToHistory()
|
|
||||||
defer rq.countAnomalies()
|
|
||||||
defer rq.countRefusal()
|
|
||||||
|
|
||||||
refused := rq.check(r.Context())
|
|
||||||
rq.checked = time.Now()
|
|
||||||
|
|
||||||
if refused != nil {
|
|
||||||
rq.answer(*refused)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// A request for smallwebwaf itself is answered where another would be
|
|
||||||
// passed to the app, so that it goes through every check first.
|
|
||||||
if strings.HasPrefix(r.URL.Path, adminPrefix) {
|
|
||||||
rq.answerAdmin()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the response has ended, before the request is added to its
|
|
||||||
// client's history. Deferred, since ReverseProxy panics to end a
|
|
||||||
// response it cannot finish.
|
|
||||||
defer rq.countBytes()
|
|
||||||
|
|
||||||
rq.forward(r.Context())
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that
|
|
||||||
// refuses the requests of a client a source lists.
|
|
||||||
const deny = "deny"
|
|
||||||
|
|
||||||
// blocklistDenied notes the blocklists that list the client, as
|
|
||||||
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
|
|
||||||
// refuses the request. Being limit, it lowers the client's limits instead
|
|
||||||
// (see limitPercentages), and being log, it does nothing more.
|
|
||||||
func (rq *request) blocklistDenied() bool {
|
|
||||||
listedBy := rq.h.lists.ListedBy(rq.client)
|
|
||||||
rq.blocklisted = len(listedBy) > 0
|
|
||||||
rq.noteListed(listedBy, "listed by a blocklist")
|
|
||||||
|
|
||||||
return rq.blocklisted && rq.h.config.BlocklistAction == deny
|
|
||||||
}
|
|
||||||
|
|
||||||
// crowdSecBanned reports whether a decision of the CrowdSec decision list
|
|
||||||
// on the client is in force at now. If one is, it notes the list, as
|
|
||||||
// noteHit does, and bans the client until that decision ends.
|
|
||||||
func (rq *request) crowdSecBanned(now time.Time) bool {
|
|
||||||
decision, listed := rq.h.lists.CrowdSecDecision(rq.client, now)
|
|
||||||
if !listed {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
|
||||||
"listed by the CrowdSec decision list")
|
|
||||||
rq.banForCrowdSec(now, decision)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
|
||||||
// noteListed does, and reports whether SWWAF_REPUTATION_ACTION, being
|
|
||||||
// deny, refuses the request. 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. ctx is the
|
|
||||||
// request's own context.
|
|
||||||
func (rq *request) dnsblDenied(ctx context.Context) bool {
|
|
||||||
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
|
|
||||||
rq.dnsblListed = len(listedBy) > 0
|
|
||||||
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
|
||||||
|
|
||||||
return rq.dnsblListed && rq.h.config.ReputationAction == deny
|
|
||||||
}
|
|
||||||
|
|
||||||
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
|
||||||
// its score of the client is a hit, and reports whether
|
|
||||||
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
|
||||||
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A
|
|
||||||
// client without a score is checked in the background, by the request's
|
|
||||||
// address, if its history counts an offence, and the request does not
|
|
||||||
// wait for the answer. The score is then used for each address of the
|
|
||||||
// client. ctx is the request's own context.
|
|
||||||
func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
|
|
||||||
if rq.h.config.AbuseIPDBKey == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
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 false
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.abuseIPDBHit = true
|
|
||||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
|
||||||
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
|
|
||||||
|
|
||||||
return rq.h.config.ReputationAction == 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,
|
|
||||||
Client: rq.client,
|
|
||||||
Netblock: rq.h.clientGroup(rq.client),
|
|
||||||
ASN: rq.line.ASN,
|
|
||||||
ASName: rq.line.ASName,
|
|
||||||
Country: rq.line.Country,
|
|
||||||
Reason: reason,
|
|
||||||
Detail: detail,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,894 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptrace"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/netip"
|
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// flushAfterEachWrite has ReverseProxy pass on each part of the app's
|
|
||||||
// answer as soon as it arrives.
|
|
||||||
const flushAfterEachWrite time.Duration = -1
|
|
||||||
|
|
||||||
// refusal is smallwebwaf refusing a request, or refusing to go on with it:
|
|
||||||
// the status the client is answered if the response has not started yet,
|
|
||||||
// 0 to close the connection without an answer, the action the log line
|
|
||||||
// names, and the setting whose size or time limit the request passed, if
|
|
||||||
// that is why.
|
|
||||||
type refusal struct {
|
|
||||||
status int
|
|
||||||
action string
|
|
||||||
limit string
|
|
||||||
}
|
|
||||||
|
|
||||||
// request is one request on its way through smallwebwaf, from the moment
|
|
||||||
// its headers have been read to its log line.
|
|
||||||
type request struct {
|
|
||||||
h *handler
|
|
||||||
in *http.Request
|
|
||||||
// rc sets the deadlines of the connection to the client.
|
|
||||||
rc *http.ResponseController
|
|
||||||
out *responseWriter
|
|
||||||
body *requestBody // nil for a request without a body
|
|
||||||
line requestlog.Line
|
|
||||||
|
|
||||||
client netip.Addr
|
|
||||||
peer netip.Addr
|
|
||||||
peerTrusted 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
|
|
||||||
// counted is true for a request the rate limits counted, whose bytes
|
|
||||||
// the byte limits count once it has ended. limitPercent and
|
|
||||||
// bytesPercent are then its client's limit percentages for the rate
|
|
||||||
// limits and for the byte limits.
|
|
||||||
counted bool
|
|
||||||
limitPercent, bytesPercent percentage
|
|
||||||
// attack is true for a request that matched a ban rule or asked for a
|
|
||||||
// trap path, ruleBlocked for one a block rule refused, wafBlocked for
|
|
||||||
// one the Core Rule Set refused, and tokenRefused for one refused for a
|
|
||||||
// missing or wrong token, each an offence its client's history counts.
|
|
||||||
attack, ruleBlocked, wafBlocked, tokenRefused bool
|
|
||||||
// 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
|
|
||||||
// reputation is the reputation sources that list the client, for the
|
|
||||||
// notes of a ban the request makes.
|
|
||||||
reputation []bans.ReputationHit
|
|
||||||
start time.Time
|
|
||||||
// checked is when the checks were done, and upstreamStart when the
|
|
||||||
// request was handed to the app.
|
|
||||||
checked time.Time
|
|
||||||
upstreamStart time.Time
|
|
||||||
// cancel ends the request to the app.
|
|
||||||
cancel context.CancelFunc
|
|
||||||
// refused is the first refusal, from whichever goroutine meets it.
|
|
||||||
refused atomic.Pointer[refusal]
|
|
||||||
// complete is true once the app's whole answer has been passed on.
|
|
||||||
complete bool
|
|
||||||
// upgraded is the connection to the app once the app has switched
|
|
||||||
// protocols, as for a WebSocket, and nil otherwise.
|
|
||||||
upgraded *upgradedConn
|
|
||||||
|
|
||||||
// mu guards what follows. The timeouts run on goroutines of their
|
|
||||||
// own, and the transport starts and stops them, and notes the times
|
|
||||||
// below, from its own; once timersStopped is set, none of the timeouts
|
|
||||||
// acts any more.
|
|
||||||
mu sync.Mutex
|
|
||||||
timersStopped bool
|
|
||||||
clientRequestTimer *time.Timer
|
|
||||||
upstreamRequestTimer *time.Timer
|
|
||||||
upstreamResponseTimer *time.Timer
|
|
||||||
// connected is when there was a connection to the app, requestSent
|
|
||||||
// when the app had been sent the whole request, and answerStarted
|
|
||||||
// when the first byte of its answer arrived.
|
|
||||||
connected time.Time
|
|
||||||
requestSent time.Time
|
|
||||||
answerStarted time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// newRequest starts handling r: it notes the time, counts the request as
|
|
||||||
// under way, works out the client, and starts the log line with what is
|
|
||||||
// known of the request.
|
|
||||||
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
|
|
||||||
h.metrics.RequestStarted()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
peer := peerAddress(r)
|
|
||||||
trusted := h.config.TrustedProxies
|
|
||||||
peerTrusted := isInside(peer, trusted)
|
|
||||||
forwardedFor := r.Header.Values("X-Forwarded-For")
|
|
||||||
client := clientAddress(peer, forwardedFor, trusted)
|
|
||||||
|
|
||||||
rq := &request{
|
|
||||||
h: h,
|
|
||||||
in: r,
|
|
||||||
rc: http.NewResponseController(w),
|
|
||||||
out: &responseWriter{ResponseWriter: w},
|
|
||||||
client: client,
|
|
||||||
peer: peer,
|
|
||||||
peerTrusted: peerTrusted,
|
|
||||||
start: start,
|
|
||||||
line: requestlog.Line{
|
|
||||||
Time: requestlog.FormatTime(start),
|
|
||||||
Instance: h.config.InstanceName,
|
|
||||||
ClientIP: client.String(),
|
|
||||||
Method: r.Method,
|
|
||||||
Scheme: scheme(r, peerTrusted),
|
|
||||||
Host: r.Host,
|
|
||||||
Path: rules.Path(r),
|
|
||||||
Query: r.URL.RawQuery,
|
|
||||||
Protocol: r.Proto,
|
|
||||||
Referer: r.Referer(),
|
|
||||||
UserAgent: r.UserAgent(),
|
|
||||||
RequestID: requestID(r, peerTrusted),
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// A length of -1 is a body whose length was not announced.
|
|
||||||
if r.ContentLength > 0 {
|
|
||||||
rq.line.ContentLength = r.ContentLength
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Body != http.NoBody {
|
|
||||||
rq.body = &requestBody{body: limitBody(r.Body, h.config.RequestMaxBytes), rq: rq}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rq
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 once its client
|
|
||||||
// is known, before anything reaches the app, and before its body is read,
|
|
||||||
// but for the part the Core Rule Set reads. It returns nil to let the
|
|
||||||
// request through. The checks of checkClient come first, answered with
|
|
||||||
// SWWAF_BAN_RESPONSE, or 403 for a block rule or the Core Rule Set, and
|
|
||||||
// then the size limit, so that a request the rate limits count is counted
|
|
||||||
// even when it is refused for its size. In observe mode a request
|
|
||||||
// checkClient refuses goes on to the size limit like any other. A size or
|
|
||||||
// time limit the Core Rule Set's reading of the body meets ends the
|
|
||||||
// request in either mode. ctx is the request's own context.
|
|
||||||
func (rq *request) check(ctx context.Context) *refusal {
|
|
||||||
action := rq.checkClient(ctx)
|
|
||||||
|
|
||||||
refused := rq.refused.Load()
|
|
||||||
if refused != nil {
|
|
||||||
return refused
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case action == "":
|
|
||||||
case rq.h.config.Observe:
|
|
||||||
// The log line names what enforce mode would have done.
|
|
||||||
rq.line.WouldAction = action
|
|
||||||
case action == requestlog.ActionRuleBlocked || action == requestlog.ActionWAFBlocked:
|
|
||||||
return &refusal{status: http.StatusForbidden, action: action}
|
|
||||||
default:
|
|
||||||
return rq.banResponse(action)
|
|
||||||
}
|
|
||||||
|
|
||||||
maxBytes := rq.h.config.RequestMaxBytes
|
|
||||||
if maxBytes > 0 && rq.in.ContentLength > maxBytes {
|
|
||||||
return &refusal{
|
|
||||||
status: http.StatusRequestEntityTooLarge,
|
|
||||||
action: requestlog.ActionTooLarge,
|
|
||||||
limit: "SWWAF_REQUEST_MAX_BYTES",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkClient runs the checks on the request's client, and returns the
|
|
||||||
// action of the first that refuses the request, or "" when none does. A
|
|
||||||
// client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any
|
|
||||||
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock,
|
|
||||||
// so that a client either refuses is not looked up, then the lookup of
|
|
||||||
// its AS number and country, then the country lists, then the blocklists,
|
|
||||||
// then the CrowdSec decision list, which bans the client it lists, then
|
|
||||||
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
|
|
||||||
// them refuses is not counted for the rate limits. Then come the
|
|
||||||
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
|
||||||
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
|
||||||
// every other request is counted, each of them by the client's limit
|
|
||||||
// percentages, then SWWAF_TRAP_PATHS, then the rule files, and last the
|
|
||||||
// Core Rule Set. A request exempt from the rate limits is exempt from the
|
|
||||||
// byte limits too. ctx is the request's own context.
|
|
||||||
func (rq *request) checkClient(ctx context.Context) string {
|
|
||||||
cfg := rq.h.config
|
|
||||||
if isInside(rq.client, cfg.AllowNets) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
now := rq.h.now()
|
|
||||||
|
|
||||||
if isInside(rq.client, cfg.DenyNets) {
|
|
||||||
return requestlog.ActionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.banned(now) {
|
|
||||||
return requestlog.ActionBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.lookUp(ctx)
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
now = rq.h.now()
|
|
||||||
|
|
||||||
if rq.countryDenied() {
|
|
||||||
return requestlog.ActionCountryDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.blocklistDenied() {
|
|
||||||
return requestlog.ActionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.crowdSecBanned(now) {
|
|
||||||
return requestlog.ActionBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.dnsblDenied(ctx) || rq.abuseIPDBDenied(ctx) {
|
|
||||||
return requestlog.ActionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.counted = !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
|
||||||
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
|
|
||||||
if rq.counted {
|
|
||||||
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
|
|
||||||
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
|
|
||||||
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.counted && rq.limitBroken(now) {
|
|
||||||
return requestlog.ActionRateLimited
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.trapPath(now) {
|
|
||||||
return requestlog.ActionBanned
|
|
||||||
}
|
|
||||||
|
|
||||||
action := rq.checkRules(now)
|
|
||||||
if action != "" {
|
|
||||||
return action
|
|
||||||
}
|
|
||||||
|
|
||||||
return rq.checkCoreRuleSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
// pathExempt reports whether r is exempt under prefixes,
|
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// forward passes the request to the app and the app's answer back. ctx
|
|
||||||
// is the request's own context.
|
|
||||||
func (rq *request) forward(ctx context.Context) {
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
rq.cancel = cancel
|
|
||||||
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
|
|
||||||
GotConn: rq.gotConn,
|
|
||||||
WroteRequest: rq.wroteRequest,
|
|
||||||
GotFirstResponseByte: rq.gotFirstResponseByte,
|
|
||||||
})
|
|
||||||
|
|
||||||
out := rq.in.WithContext(ctx)
|
|
||||||
if rq.body != nil {
|
|
||||||
out.Body = rq.body
|
|
||||||
}
|
|
||||||
|
|
||||||
reverseProxy := &httputil.ReverseProxy{
|
|
||||||
Rewrite: rq.rewrite,
|
|
||||||
Transport: rq.h.transport,
|
|
||||||
FlushInterval: flushAfterEachWrite,
|
|
||||||
ErrorLog: rq.h.errorLog,
|
|
||||||
ModifyResponse: rq.modifyResponse,
|
|
||||||
ErrorHandler: rq.answerError,
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.startRequestTimers()
|
|
||||||
rq.upstreamStart = time.Now()
|
|
||||||
reverseProxy.ServeHTTP(rq.out, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// rewrite makes the request the app receives: the client's request,
|
|
||||||
// unchanged, sent to SWWAF_UPSTREAM_URL, with the forwarded headers and
|
|
||||||
// the request's id set, without any X-Client-ASN or X-Client-Country the
|
|
||||||
// client sent, whatever SWWAF_ADD_LOOKUP_HEADERS says, and, while it is
|
|
||||||
// set, with the client's AS number and country in them.
|
|
||||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
|
||||||
upstream := rq.h.config.UpstreamURL
|
|
||||||
pr.Out.URL.Scheme = upstream.Scheme
|
|
||||||
pr.Out.URL.Host = upstream.Host
|
|
||||||
// ReverseProxy drops query parameters it cannot parse; the app gets
|
|
||||||
// the query as the client sent it.
|
|
||||||
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
|
||||||
// ReverseProxy sends the path URL.EscapedPath gives, which, for a path
|
|
||||||
// holding a character Go escapes, such as |, is the whole path decoded
|
|
||||||
// and escaped again, %2F turned into /. Opaque is sent as it is, so the
|
|
||||||
// app gets the path as the client sent it. Left out are a target that
|
|
||||||
// is no path, such as *, and a path starting with //, which Go would
|
|
||||||
// send in Opaque as a scheme and host.
|
|
||||||
path := rules.Path(pr.In)
|
|
||||||
if strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//") {
|
|
||||||
pr.Out.URL.Opaque = path
|
|
||||||
}
|
|
||||||
|
|
||||||
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
|
||||||
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
|
|
||||||
pr.Out.Header.Del(asnHeader)
|
|
||||||
pr.Out.Header.Del(countryHeader)
|
|
||||||
|
|
||||||
if rq.h.config.AddLookupHeaders {
|
|
||||||
setLookupHeaders(pr.Out.Header, rq.line.ASN, rq.line.Country)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// modifyResponse looks at the app's answer before ReverseProxy passes it
|
|
||||||
// on.
|
|
||||||
func (rq *request) modifyResponse(res *http.Response) error {
|
|
||||||
rq.line.UpstreamStatus = res.StatusCode
|
|
||||||
|
|
||||||
if res.StatusCode == http.StatusSwitchingProtocols {
|
|
||||||
// An upgraded connection, such as a WebSocket, is not cut by the
|
|
||||||
// timeouts. ReverseProxy writes this answer straight to the
|
|
||||||
// connection it takes over, not through rq.out, and then copies
|
|
||||||
// what passes each way through res.Body, the connection to the app.
|
|
||||||
// It may still refuse the switch, as when the app's Upgrade is not
|
|
||||||
// the client's, so the status is noted only once it has taken over
|
|
||||||
// the connection, by rq.out's Hijack.
|
|
||||||
rq.stopTimers()
|
|
||||||
rq.line.Websocket = true
|
|
||||||
|
|
||||||
conn, ok := res.Body.(io.ReadWriteCloser)
|
|
||||||
if ok {
|
|
||||||
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
|
|
||||||
res.Body = rq.upgraded
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
maxBytes := rq.h.config.ResponseMaxBytes
|
|
||||||
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
|
|
||||||
rq.refuse(refusal{
|
|
||||||
status: http.StatusBadGateway,
|
|
||||||
action: requestlog.ActionTooLarge,
|
|
||||||
limit: "SWWAF_RESPONSE_MAX_BYTES",
|
|
||||||
})
|
|
||||||
|
|
||||||
return errResponseTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go's server adds a Content-Type it guesses from the body to a
|
|
||||||
// response whose headers have no Content-Type key. The key with no
|
|
||||||
// value sends none, as the app sent none.
|
|
||||||
if res.Header.Get("Content-Type") == "" {
|
|
||||||
rq.out.Header()["Content-Type"] = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
|
|
||||||
rq.startClientResponseTimeout()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// answerError is ReverseProxy's ErrorHandler: the request could not be
|
|
||||||
// passed to the app, or the app's answer cannot be passed on.
|
|
||||||
func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) {
|
|
||||||
if rq.upgraded != nil {
|
|
||||||
// The app switched protocols, but the switch was not passed on,
|
|
||||||
// and ReverseProxy leaves the app's connection open when it
|
|
||||||
// refuses it.
|
|
||||||
_ = rq.upgraded.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
refused := rq.refused.Load()
|
|
||||||
if refused == nil {
|
|
||||||
if rq.in.Context().Err() != nil {
|
|
||||||
return // the client has gone, and there is no one to answer
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.h.processLog.Warn("request to the app failed", "error", err.Error())
|
|
||||||
|
|
||||||
refused = &refusal{
|
|
||||||
status: http.StatusBadGateway,
|
|
||||||
action: requestlog.ActionUpstreamError,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.answer(*refused)
|
|
||||||
}
|
|
||||||
|
|
||||||
// answer sends smallwebwaf's own answer, unless the response has already
|
|
||||||
// started, and records the refusal for the log line.
|
|
||||||
func (rq *request) answer(r refusal) {
|
|
||||||
rq.refused.CompareAndSwap(nil, &r)
|
|
||||||
|
|
||||||
if rq.out.status != 0 {
|
|
||||||
return // too late to answer: the connection can only be cut
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.status == 0 {
|
|
||||||
// SWWAF_BAN_RESPONSE is close. This panic has Go's server close
|
|
||||||
// the connection without an answer, and log nothing; the log line
|
|
||||||
// is still written as the handler returns.
|
|
||||||
panic(http.ErrAbortHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A client found too slow is read no more; any other may go on
|
|
||||||
// sending until its time is up, so that Go's server can read the
|
|
||||||
// rest of the body and end the request cleanly.
|
|
||||||
deadline := rq.clientRequestDeadline()
|
|
||||||
if r.status == http.StatusRequestTimeout {
|
|
||||||
deadline = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.stopReadingBody(deadline)
|
|
||||||
|
|
||||||
timeout := rq.h.config.ClientResponseTimeout
|
|
||||||
if timeout > 0 {
|
|
||||||
_ = rq.rc.SetWriteDeadline(time.Now().Add(timeout))
|
|
||||||
}
|
|
||||||
|
|
||||||
http.Error(rq.out, http.StatusText(r.status), r.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refuse records r, unless an earlier refusal was, and ends the request
|
|
||||||
// to the app, if one was made: smallwebwaf reads the body of a request
|
|
||||||
// it answers itself too.
|
|
||||||
func (rq *request) refuse(r refusal) {
|
|
||||||
rq.refused.CompareAndSwap(nil, &r)
|
|
||||||
|
|
||||||
if rq.cancel != nil {
|
|
||||||
rq.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// finish ends the request's timeouts, counts it in the metrics and writes
|
|
||||||
// its log line.
|
|
||||||
func (rq *request) finish() {
|
|
||||||
rq.stopTimers()
|
|
||||||
|
|
||||||
refused := rq.refused.Load()
|
|
||||||
if refused == nil {
|
|
||||||
rq.stopReadingBody(rq.clientRequestDeadline())
|
|
||||||
}
|
|
||||||
|
|
||||||
line := &rq.line
|
|
||||||
line.Status = rq.out.status
|
|
||||||
line.ResponseBytes = rq.out.bytes
|
|
||||||
header := rq.out.Header()
|
|
||||||
line.ResponseContentType = header.Get("Content-Type")
|
|
||||||
line.CacheControl = header.Get("Cache-Control")
|
|
||||||
line.Location = header.Get("Location")
|
|
||||||
line.RequestBytes = rq.requestBytes()
|
|
||||||
|
|
||||||
// limit is the setting whose size or time limit the request passed.
|
|
||||||
var limit string
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case refused != nil:
|
|
||||||
line.Action = refused.action
|
|
||||||
limit = refused.limit
|
|
||||||
case errors.Is(rq.out.err, os.ErrDeadlineExceeded):
|
|
||||||
// The client took longer than SWWAF_CLIENT_RESPONSE_TIMEOUT to
|
|
||||||
// take the response.
|
|
||||||
line.Action = requestlog.ActionTimedOut
|
|
||||||
limit = "SWWAF_CLIENT_RESPONSE_TIMEOUT"
|
|
||||||
case !rq.complete && (rq.out.err != nil || rq.in.Context().Err() != nil):
|
|
||||||
line.Aborted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
duration := now.Sub(rq.start)
|
|
||||||
line.DurationTotal = requestlog.Milliseconds(duration)
|
|
||||||
line.DurationChecks = timing(rq.start, rq.checked)
|
|
||||||
|
|
||||||
var upstreamDuration time.Duration
|
|
||||||
|
|
||||||
if !rq.upstreamStart.IsZero() {
|
|
||||||
upstreamDuration = now.Sub(rq.upstreamStart)
|
|
||||||
line.DurationUpstreamTotal = new(requestlog.Milliseconds(upstreamDuration))
|
|
||||||
|
|
||||||
rq.mu.Lock()
|
|
||||||
line.DurationUpstreamConnect = timing(rq.upstreamStart, rq.connected)
|
|
||||||
line.DurationUpstreamFirstByte = timing(rq.upstreamStart, rq.answerStarted)
|
|
||||||
rq.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counted before the log line is written, so that the metrics count
|
|
||||||
// every request whose line is out.
|
|
||||||
rq.h.metrics.RequestEnded(line, limit, duration, upstreamDuration)
|
|
||||||
|
|
||||||
err := requestlog.Write(rq.h.requestLog, 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() {
|
|
||||||
forwarded := !rq.upstreamStart.IsZero()
|
|
||||||
request := ratelimit.Request{
|
|
||||||
Forwarded: forwarded,
|
|
||||||
Refused: !forwarded && rq.refused.Load() != nil,
|
|
||||||
Status: rq.out.status,
|
|
||||||
RequestBytes: rq.requestBytes(),
|
|
||||||
ResponseBytes: rq.out.bytes,
|
|
||||||
BrokeLimit: rq.line.Offence == requestlog.OffenceLimit,
|
|
||||||
Attack: rq.attack,
|
|
||||||
RuleBlocked: rq.ruleBlocked,
|
|
||||||
WAFBlocked: rq.wafBlocked,
|
|
||||||
TokenRefused: rq.tokenRefused,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 SWWAF_ALLOW_NETS or
|
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in
|
|
||||||
// SWWAF_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{
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestBytes is how many bytes of the request's body have been read.
|
|
||||||
func (rq *request) requestBytes() int64 {
|
|
||||||
if rq.body == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return rq.body.bytes.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientRequestDeadline is when the client must have sent its whole
|
|
||||||
// request, or zero when SWWAF_CLIENT_REQUEST_TIMEOUT is off.
|
|
||||||
func (rq *request) clientRequestDeadline() time.Time {
|
|
||||||
timeout := rq.h.config.ClientRequestTimeout
|
|
||||||
if timeout == 0 {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rq.start.Add(timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stopReadingBody ends, at deadline, the reading of a client body that has
|
|
||||||
// not arrived whole: Go's server then reads no more of it, and closes the
|
|
||||||
// connection after the answer.
|
|
||||||
func (rq *request) stopReadingBody(deadline time.Time) {
|
|
||||||
if rq.body == nil || rq.body.received.Load() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = rq.rc.SetReadDeadline(deadline)
|
|
||||||
}
|
|
||||||
|
|
||||||
// startRequestTimers starts the timeouts that run while the request goes
|
|
||||||
// to the app: SWWAF_CLIENT_REQUEST_TIMEOUT until the client has sent its
|
|
||||||
// whole body, and SWWAF_UPSTREAM_REQUEST_TIMEOUT until the app has been
|
|
||||||
// sent the whole request.
|
|
||||||
func (rq *request) startRequestTimers() {
|
|
||||||
rq.mu.Lock()
|
|
||||||
defer rq.mu.Unlock()
|
|
||||||
|
|
||||||
if rq.body != nil && rq.h.config.ClientRequestTimeout > 0 {
|
|
||||||
rq.clientRequestTimer = time.AfterFunc(
|
|
||||||
time.Until(rq.clientRequestDeadline()), func() {
|
|
||||||
rq.requestTimedOut("SWWAF_CLIENT_REQUEST_TIMEOUT")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout := rq.h.config.UpstreamRequestTimeout
|
|
||||||
if timeout > 0 {
|
|
||||||
rq.upstreamRequestTimer = time.AfterFunc(timeout, func() {
|
|
||||||
rq.requestTimedOut("SWWAF_UPSTREAM_REQUEST_TIMEOUT")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// requestTimedOut is called when limit, SWWAF_CLIENT_REQUEST_TIMEOUT or
|
|
||||||
// SWWAF_UPSTREAM_REQUEST_TIMEOUT, runs out while the request is still on
|
|
||||||
// its way to the app. The answer names the side smallwebwaf was waiting
|
|
||||||
// on at that moment: 408 when it was waiting for the client to send more
|
|
||||||
// of its body, 504 when it was waiting for the app to be reached or to
|
|
||||||
// take what it had.
|
|
||||||
func (rq *request) requestTimedOut(limit string) {
|
|
||||||
rq.mu.Lock()
|
|
||||||
defer rq.mu.Unlock()
|
|
||||||
|
|
||||||
if rq.timersStopped {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if rq.body == nil || !rq.body.waiting.Load() {
|
|
||||||
// Once the app has answered, the transport, ReverseProxy and Go's
|
|
||||||
// server each close the client's body, and closing it reads what
|
|
||||||
// is left of a body under 256 KiB without requestBody.Read, so
|
|
||||||
// waiting is false while that read waits for the client. That
|
|
||||||
// read ends now, before refuse: should smallwebwaf still answer
|
|
||||||
// itself, answer then sets the deadline it gives its own answers.
|
|
||||||
if !rq.answerStarted.IsZero() {
|
|
||||||
rq.stopReadingBody(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.refuse(refusal{
|
|
||||||
status: http.StatusGatewayTimeout,
|
|
||||||
action: requestlog.ActionTimedOut,
|
|
||||||
limit: limit,
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.refuse(refusal{
|
|
||||||
status: http.StatusRequestTimeout,
|
|
||||||
action: requestlog.ActionTimedOut,
|
|
||||||
limit: limit,
|
|
||||||
})
|
|
||||||
// The transport gives up on the app only once its Read of the
|
|
||||||
// client's body returns, so that Read is ended now. The lock keeps
|
|
||||||
// this from reaching the connection after the request is handled.
|
|
||||||
_ = rq.rc.SetReadDeadline(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
// bodyReceived is called once the client has sent its whole body.
|
|
||||||
func (rq *request) bodyReceived() {
|
|
||||||
rq.mu.Lock()
|
|
||||||
defer rq.mu.Unlock()
|
|
||||||
|
|
||||||
stopTimer(rq.clientRequestTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// gotConn is called once there is a connection to the app, a new one or
|
|
||||||
// one kept open from an earlier request.
|
|
||||||
func (rq *request) gotConn(httptrace.GotConnInfo) {
|
|
||||||
rq.mu.Lock()
|
|
||||||
defer rq.mu.Unlock()
|
|
||||||
|
|
||||||
rq.connected = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// gotFirstResponseByte is called once the first byte of the app's answer
|
|
||||||
// has arrived.
|
|
||||||
func (rq *request) gotFirstResponseByte() {
|
|
||||||
rq.mu.Lock()
|
|
||||||
defer rq.mu.Unlock()
|
|
||||||
|
|
||||||
rq.answerStarted = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// wroteRequest is called once the app has been sent the whole request:
|
|
||||||
// the request timeouts end and SWWAF_UPSTREAM_RESPONSE_TIMEOUT starts.
|
|
||||||
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
|
|
||||||
if info.Err != nil {
|
|
||||||
return // the transport gives up, or tries again
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.mu.Lock()
|
|
||||||
defer rq.mu.Unlock()
|
|
||||||
|
|
||||||
if rq.timersStopped {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stopTimer(rq.clientRequestTimer)
|
|
||||||
stopTimer(rq.upstreamRequestTimer)
|
|
||||||
rq.requestSent = time.Now()
|
|
||||||
|
|
||||||
timeout := rq.h.config.UpstreamResponseTimeout
|
|
||||||
if timeout > 0 {
|
|
||||||
rq.upstreamResponseTimer = time.AfterFunc(timeout, rq.responseTimedOut)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// responseTimedOut is called when SWWAF_UPSTREAM_RESPONSE_TIMEOUT runs out
|
|
||||||
// before the app has sent its whole answer.
|
|
||||||
func (rq *request) responseTimedOut() {
|
|
||||||
rq.mu.Lock()
|
|
||||||
defer rq.mu.Unlock()
|
|
||||||
|
|
||||||
if !rq.timersStopped {
|
|
||||||
rq.refuse(refusal{
|
|
||||||
status: http.StatusGatewayTimeout,
|
|
||||||
action: requestlog.ActionTimedOut,
|
|
||||||
limit: "SWWAF_UPSTREAM_RESPONSE_TIMEOUT",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// responseReceived is called once the app has sent its whole answer. An
|
|
||||||
// app may answer before the client has sent its whole body, as with a 404
|
|
||||||
// it sends without reading it. The timeouts then run on: until the
|
|
||||||
// transport's read of the body returns, Go's server cannot send the
|
|
||||||
// answer, and SWWAF_CLIENT_REQUEST_TIMEOUT is what ends that read for a
|
|
||||||
// client that stops sending.
|
|
||||||
func (rq *request) responseReceived() {
|
|
||||||
rq.complete = true
|
|
||||||
|
|
||||||
if rq.body != nil && !rq.body.received.Load() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.stopTimers()
|
|
||||||
}
|
|
||||||
|
|
||||||
// startClientResponseTimeout sets SWWAF_CLIENT_RESPONSE_TIMEOUT on the
|
|
||||||
// connection to the client: the response must reach the client within it
|
|
||||||
// of the end of the request, or of now if the app answers before it has
|
|
||||||
// the whole request.
|
|
||||||
func (rq *request) startClientResponseTimeout() {
|
|
||||||
timeout := rq.h.config.ClientResponseTimeout
|
|
||||||
if timeout == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
from := rq.sentAt()
|
|
||||||
if from.IsZero() {
|
|
||||||
from = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = rq.rc.SetWriteDeadline(from.Add(timeout))
|
|
||||||
}
|
|
||||||
|
|
||||||
// sentAt is when the app had been sent the whole request, or zero.
|
|
||||||
func (rq *request) sentAt() time.Time {
|
|
||||||
rq.mu.Lock()
|
|
||||||
defer rq.mu.Unlock()
|
|
||||||
|
|
||||||
return rq.requestSent
|
|
||||||
}
|
|
||||||
|
|
||||||
// stopTimers stops the request's timeouts and keeps any from starting
|
|
||||||
// later: the app's answer is complete, the connection upgraded, or the
|
|
||||||
// request handled.
|
|
||||||
func (rq *request) stopTimers() {
|
|
||||||
rq.mu.Lock()
|
|
||||||
defer rq.mu.Unlock()
|
|
||||||
|
|
||||||
rq.timersStopped = true
|
|
||||||
stopTimer(rq.clientRequestTimer)
|
|
||||||
stopTimer(rq.upstreamRequestTimer)
|
|
||||||
stopTimer(rq.upstreamResponseTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// stopTimer stops t, which is nil when its timeout is off.
|
|
||||||
func stopTimer(t *time.Timer) {
|
|
||||||
if t != nil {
|
|
||||||
t.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
rq := &request{
|
|
||||||
h: &handler{config: &config.Config{}},
|
|
||||||
out: &responseWriter{ResponseWriter: w},
|
|
||||||
}
|
|
||||||
app := &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Header: http.Header{},
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := rq.modifyResponse(app)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("modifyResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The headers go out with this, the first part of the body,
|
|
||||||
// which Go's server would guess a Content-Type from.
|
|
||||||
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL,
|
|
||||||
http.NoBody)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := srv.Client().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = res.Body.Close()
|
|
||||||
|
|
||||||
if res.Header.Values("Content-Type") != nil {
|
|
||||||
t.Errorf("client got Content-Type %q, want none",
|
|
||||||
res.Header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
// trapPath reports whether the request asks for a path in
|
|
||||||
// SWWAF_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.
|
|
||||||
func (rq *request) trapPath(now time.Time) bool {
|
|
||||||
path := rules.Path(rq.in)
|
|
||||||
if !slices.Contains(rq.h.config.TrapPaths, path) &&
|
|
||||||
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.attack = true
|
|
||||||
rq.banForAttack(now, bans.Notes{TrapPath: path})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkRules checks the request against the rules of the rule files at
|
|
||||||
// now, notes the ids of those it matches in the log line, and returns the
|
|
||||||
// action of the rule that refuses it, ActionRuleBlocked for a block rule
|
|
||||||
// and ActionBanned for a ban rule, or "" when none does. 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.
|
|
||||||
func (rq *request) checkRules(now time.Time) string {
|
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the last rule matched can refuse the request.
|
|
||||||
switch last := matched[len(matched)-1]; last.Action {
|
|
||||||
case rules.ActionBlock:
|
|
||||||
rq.ruleBlocked = true
|
|
||||||
|
|
||||||
return requestlog.ActionRuleBlocked
|
|
||||||
case rules.ActionBan:
|
|
||||||
rq.attack = true
|
|
||||||
rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target})
|
|
||||||
|
|
||||||
return requestlog.ActionBanned
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
package proxy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// largeBodySize is more than the connections between the client,
|
|
||||||
// smallwebwaf and the app can hold while nobody reads, so that a sender
|
|
||||||
// soon waits.
|
|
||||||
const largeBodySize = 64 << 20
|
|
||||||
|
|
||||||
// writeSize is how much a test sender writes at a time.
|
|
||||||
const writeSize = 32 << 10
|
|
||||||
|
|
||||||
func TestRequestTimeouts(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
// limit is the setting set to shortTimeout, which runs out; long
|
|
||||||
// is one set to longTimeoutSetting, which does not, or "".
|
|
||||||
limit, long string
|
|
||||||
// appTakesNothing has the app never read, while the client sends
|
|
||||||
// as fast as it can; otherwise the app reads, and the client
|
|
||||||
// stops sending halfway.
|
|
||||||
appTakesNothing bool
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "client request timeout, waiting on the client",
|
|
||||||
limit: clientRequestTimeout,
|
|
||||||
want: http.StatusRequestTimeout,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream request timeout, waiting on the client",
|
|
||||||
limit: upstreamRequestTimeout,
|
|
||||||
long: clientRequestTimeout,
|
|
||||||
want: http.StatusRequestTimeout,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream request timeout, waiting on the app",
|
|
||||||
limit: upstreamRequestTimeout,
|
|
||||||
appTakesNothing: true,
|
|
||||||
want: http.StatusGatewayTimeout,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "client request timeout, waiting on the app",
|
|
||||||
limit: clientRequestTimeout,
|
|
||||||
long: upstreamRequestTimeout,
|
|
||||||
appTakesNothing: true,
|
|
||||||
want: http.StatusGatewayTimeout,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
app *httptest.Server
|
|
||||||
appURL string
|
|
||||||
sendRequest func(*testing.T, string) net.Conn
|
|
||||||
appGotBody atomic.Bool
|
|
||||||
)
|
|
||||||
|
|
||||||
if tc.appTakesNothing {
|
|
||||||
appURL, sendRequest = startAppThatTakesNothing(t), sendLargeBody
|
|
||||||
} else {
|
|
||||||
app = startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
n, _ := io.Copy(io.Discard, r.Body)
|
|
||||||
appGotBody.Store(n > 0)
|
|
||||||
})
|
|
||||||
appURL, sendRequest = app.URL, sendPartOfBody
|
|
||||||
}
|
|
||||||
|
|
||||||
env := map[string]string{tc.limit: shortTimeoutSetting, metricsToken: token}
|
|
||||||
if tc.long != "" {
|
|
||||||
env[tc.long] = longTimeoutSetting
|
|
||||||
}
|
|
||||||
|
|
||||||
addr, out := startProxy(t, appURL, env)
|
|
||||||
start := time.Now()
|
|
||||||
got := readResponse(t, sendRequest(t, addr))
|
|
||||||
wantTimedOut(t, start)
|
|
||||||
|
|
||||||
want := tc.want
|
|
||||||
|
|
||||||
if app != nil {
|
|
||||||
// Close returns once the app has finished with the request.
|
|
||||||
app.Close()
|
|
||||||
|
|
||||||
// Until some of the body has reached the app, smallwebwaf
|
|
||||||
// waits on the app, and SPEC.md asks for 504; the timeout
|
|
||||||
// runs out then only if the test process is held up.
|
|
||||||
if !appGotBody.Load() {
|
|
||||||
want = http.StatusGatewayTimeout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wantStatus(t, got, want)
|
|
||||||
wantLine(t, out.requestLine(t), want, requestlog.ActionTimedOut)
|
|
||||||
wantLimitHits(t, addr, tc.limit, 1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startAppThatTakesNothing starts an app that accepts connections and
|
|
||||||
// never reads from them, and returns its URL.
|
|
||||||
func startAppThatTakesNothing(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
held []net.Conn
|
|
||||||
)
|
|
||||||
|
|
||||||
hold := func(conn net.Conn) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
held = append(held, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hold(conn)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = listener.Close()
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
for _, conn := range held {
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return "http://" + listener.Addr().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendPartOfBody sends a request that announces a large body, and only
|
|
||||||
// the first bytes of it.
|
|
||||||
func sendPartOfBody(t *testing.T, addr string) net.Conn {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: "+
|
|
||||||
strconv.Itoa(largeBodySize)+"\r\n\r\nthe first bytes")
|
|
||||||
|
|
||||||
return conn
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendLargeBody sends a request with a large body, as fast as smallwebwaf
|
|
||||||
// takes it, from a goroutine of its own.
|
|
||||||
func sendLargeBody(t *testing.T, addr string) net.Conn {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: "+
|
|
||||||
strconv.Itoa(largeBodySize)+"\r\n\r\n")
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
chunk := make([]byte, writeSize)
|
|
||||||
for range largeBodySize / writeSize {
|
|
||||||
_, err := conn.Write(chunk)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return conn
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientStopsSendingAfterTheAppHasAnswered(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// answeredAt is when the app answered, or nil if the bytes the client
|
|
||||||
// sent never reached it.
|
|
||||||
var answeredAt atomic.Pointer[time.Time]
|
|
||||||
|
|
||||||
// The app answers without reading the whole body, as an app does for
|
|
||||||
// a path it does not serve, and only once smallwebwaf waits for the
|
|
||||||
// rest of it: the app takes the bytes the client sent, and then waits
|
|
||||||
// half the timeout, far longer than smallwebwaf takes to go on to
|
|
||||||
// wait.
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, err := io.ReadFull(r.Body, make([]byte, len("the first bytes")))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(shortTimeout / 2)
|
|
||||||
answeredAt.Store(new(time.Now()))
|
|
||||||
http.NotFound(w, r)
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
clientRequestTimeout: shortTimeoutSetting,
|
|
||||||
metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
got := readResponse(t, sendPartOfBody(t, addr))
|
|
||||||
wantTimedOut(t, start)
|
|
||||||
|
|
||||||
// Close returns once the app has finished with the request.
|
|
||||||
app.Close()
|
|
||||||
|
|
||||||
// The app's answer came before the time ran out, so the client is
|
|
||||||
// sent that answer, not smallwebwaf's 408. Only a hold-up of the test
|
|
||||||
// process can make it come later, and SPEC.md then asks for 408, as
|
|
||||||
// smallwebwaf was waiting for the client, or for 504 if the bytes the
|
|
||||||
// client sent had not reached the app.
|
|
||||||
at := answeredAt.Load()
|
|
||||||
want := http.StatusNotFound
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case at == nil:
|
|
||||||
want = http.StatusGatewayTimeout
|
|
||||||
case at.Sub(start) >= shortTimeout:
|
|
||||||
want = http.StatusRequestTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
wantStatus(t, got, want)
|
|
||||||
wantLine(t, out.requestLine(t), want, requestlog.ActionTimedOut)
|
|
||||||
wantLimitHits(t, addr, clientRequestTimeout, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientStopsSendingWithLittleOfItsBodyLeft(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
// limit is the setting set to shortTimeout, which runs out, and
|
|
||||||
// clientTimeout what SWWAF_CLIENT_REQUEST_TIMEOUT is set to when it
|
|
||||||
// is not limit.
|
|
||||||
limit, clientTimeout string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "client request timeout",
|
|
||||||
limit: clientRequestTimeout,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream request timeout, client request timeout off",
|
|
||||||
limit: upstreamRequestTimeout,
|
|
||||||
clientTimeout: off,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "upstream request timeout, client request timeout longer",
|
|
||||||
limit: upstreamRequestTimeout,
|
|
||||||
clientTimeout: longTimeoutSetting,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// appClosed is closed once smallwebwaf has closed its
|
|
||||||
// connection to the app, which it does once it has the app's
|
|
||||||
// answer.
|
|
||||||
appClosed := make(chan struct{})
|
|
||||||
|
|
||||||
// The app answers without reading the rest of the body, and
|
|
||||||
// says it closes the connection, only once smallwebwaf waits
|
|
||||||
// for the client to send more: it takes the bytes the client
|
|
||||||
// sent, and then waits half the timeout, far longer than
|
|
||||||
// smallwebwaf takes to go on to wait.
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
conn, buf, err := http.NewResponseController(w).Hijack()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = io.ReadFull(buf.Reader, make([]byte, len("the first bytes")))
|
|
||||||
|
|
||||||
time.Sleep(shortTimeout / 2)
|
|
||||||
|
|
||||||
_, _ = buf.WriteString("HTTP/1.1 404 Not Found\r\n" +
|
|
||||||
"Content-Length: 0\r\nConnection: close\r\n\r\n")
|
|
||||||
_ = buf.Flush()
|
|
||||||
_, _ = io.Copy(io.Discard, buf.Reader)
|
|
||||||
_ = conn.Close()
|
|
||||||
|
|
||||||
close(appClosed)
|
|
||||||
})
|
|
||||||
|
|
||||||
env := map[string]string{tc.limit: shortTimeoutSetting, metricsToken: token}
|
|
||||||
if tc.clientTimeout != "" {
|
|
||||||
env[clientRequestTimeout] = tc.clientTimeout
|
|
||||||
}
|
|
||||||
|
|
||||||
addr, out := startProxy(t, app.URL, env)
|
|
||||||
|
|
||||||
// Less than 256 KiB of the body is left after the first bytes,
|
|
||||||
// little enough for Go's server in smallwebwaf to read the rest
|
|
||||||
// itself before it sends the app's answer.
|
|
||||||
start := time.Now()
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: 200000\r\n\r\n"+
|
|
||||||
"the first bytes")
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-appClosed:
|
|
||||||
case <-time.After(waitLimit):
|
|
||||||
t.Fatal("smallwebwaf did not close its connection to the app")
|
|
||||||
}
|
|
||||||
|
|
||||||
// More of the body, which can no longer go to the app, and then
|
|
||||||
// nothing.
|
|
||||||
send(t, conn, strings.Repeat("x", 8<<10))
|
|
||||||
|
|
||||||
got := readResponse(t, conn)
|
|
||||||
wantTimedOut(t, start)
|
|
||||||
wantStatus(t, got, http.StatusNotFound)
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusNotFound, requestlog.ActionTimedOut)
|
|
||||||
wantLimitHits(t, addr, tc.limit, 1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppTooSlowToAnswer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
<-r.Context().Done()
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
upstreamResponseTimeout: shortTimeoutSetting,
|
|
||||||
metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, "/slow"), http.StatusGatewayTimeout)
|
|
||||||
wantTimedOut(t, start)
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantLine(t, line, http.StatusGatewayTimeout, requestlog.ActionTimedOut)
|
|
||||||
|
|
||||||
_, answered := line.fields["upstream_status"]
|
|
||||||
if answered {
|
|
||||||
t.Errorf("log line has upstream_status %v for an app that never answered",
|
|
||||||
line.fields["upstream_status"])
|
|
||||||
}
|
|
||||||
|
|
||||||
wantLimitHits(t, addr, upstreamResponseTimeout, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppTooSlowToFinishItsAnswer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = io.WriteString(w, "the first part")
|
|
||||||
_ = http.NewResponseController(w).Flush()
|
|
||||||
|
|
||||||
<-r.Context().Done()
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
upstreamResponseTimeout: shortTimeoutSetting,
|
|
||||||
})
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
got := get(t, addr, "/slow")
|
|
||||||
wantStatus(t, got, http.StatusOK)
|
|
||||||
|
|
||||||
if string(got.body) != "the first part" || !errors.Is(got.err, io.ErrUnexpectedEOF) {
|
|
||||||
t.Errorf("client read %q (%v), want the first part cut off", got.body, got.err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantTimedOut(t, start)
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionTimedOut)
|
|
||||||
|
|
||||||
if line.UpstreamStatus != http.StatusOK {
|
|
||||||
t.Errorf("log line has upstream_status %d, want %d",
|
|
||||||
line.UpstreamStatus, http.StatusOK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClientTooSlowToTakeTheAnswer(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
chunk := make([]byte, writeSize)
|
|
||||||
for range largeBodySize / writeSize {
|
|
||||||
_, err := w.Write(chunk)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
addr, out := startProxy(t, app.URL, map[string]string{
|
|
||||||
clientResponseTimeout: shortTimeoutSetting,
|
|
||||||
metricsToken: token,
|
|
||||||
})
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
// The client asks, and never reads the answer.
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "GET /large HTTP/1.1\r\nHost: app\r\n\r\n")
|
|
||||||
|
|
||||||
line := out.requestLine(t)
|
|
||||||
wantTimedOut(t, start)
|
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionTimedOut)
|
|
||||||
wantLimitHits(t, addr, clientResponseTimeout, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClosesAnIdleConnection(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
|
||||||
addr, _ := startProxy(t, app.URL, map[string]string{
|
|
||||||
clientIdleTimeout: shortTimeoutSetting,
|
|
||||||
})
|
|
||||||
|
|
||||||
// The idle time starts once the answer is sent, so after start.
|
|
||||||
start := time.Now()
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "GET / HTTP/1.1\r\nHost: app\r\n\r\n")
|
|
||||||
wantStatus(t, readResponse(t, conn), http.StatusOK)
|
|
||||||
|
|
||||||
// The read deadline readResponse set still bounds this read.
|
|
||||||
_, err := conn.Read(make([]byte, 1))
|
|
||||||
if !errors.Is(err, io.EOF) {
|
|
||||||
t.Fatalf("read on the idle connection: %v, want it closed", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantTimedOut(t, start)
|
|
||||||
}
|
|
||||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user