Author SHA1 Message Date
sneak 6d78846014 Set the images' own OCI labels instead of Ubuntu's (closes #216)
check / check (push) Failing after 3m38s
The image sets title, description, version, created, revision and
source; the last three known only at build time come from build
arguments that default to unknown, and script/docker and
script/cibuild pass them. The example app sets its own title and
description.

Model: opus-5-5
2026-11-07 12:39:34 +00:00
clawbot beb503b233 Rename the program to sww and its deciding daemon to discriminator (closes #218) (#221)
check / check (push) Failing after 1m34s
2026-11-07 06:51:06 +01:00
clawbot 2e2f6f7175 Core Rule Set: gitea's ordinary requests pass by default (#208)
check / check (push) Successful in 3m29s
2026-11-07 05:21:22 +01:00
clawbot 7fed6b6d36 Remove what is about deploying behind Gitea (#210)
check / check (push) Successful in 3m30s
2026-11-07 03:52:02 +01:00
sneak f8676debda Merge branch 'main' into next
check / check (push) Successful in 5m27s
2026-11-07 02:52:20 +01:00
clawbot a41d61e9ad Install the image's Ubuntu packages from the normal mirrors (closes #190)
check / check (push) Successful in 3m14s
The image's `ca-certificates`, `nix-bin` and `runit` now come from Ubuntu's archive instead of a dated snapshot, whose service had failed image builds. apt fetches them over plain HTTP and checks each against the archive's signed package lists, so no CA certificates are borrowed from the Go image. The README, `SPEC.md` and `docs/image.md` say so; the amd64-only limit, which came from the removed hash check, is gone.

Rule cut against: `REPO_POLICIES.md` pins every external reference by hash; sneak's ruling at sneak/prompts#72 (comment) allows Ubuntu's own package index.

Model: deepseek-v4-pro (implementation), opus-5-5 (rework)
2026-11-06 19:15:28 +00:00
clawbot 21cac70b50 Lower the default request and byte limits (closes #193)
check / check (push) Successful in 3m12s
New defaults, as sneak approved: 120 requests a minute, 1,500 an hour and 6,000 a day; bytes off a minute, 50G an hour and 100G a day. The README, `SPEC.md`, `docs/settings.md` and `docs/gitea.md` give the new figures and reasoning, and point Actions runners and shared addresses at `SWWAF_RATE_LIMIT_EXEMPT_NETS`. The README examples now hold against the new defaults.

An Actions runner that is not exempted makes more requests an hour than the new limit and will be banned.

Model: opus-5-5
2026-11-06 20:14:23 +01:00
clawbot 2ab57eb278 Install the image's Ubuntu packages from the normal mirrors (closes #190)
check / check (push) Successful in 3m6s
The image's `ca-certificates`, `nix-bin` and `runit` now come from Ubuntu's archive instead of a dated snapshot, whose service had failed image builds. apt fetches them over plain HTTP and checks each against the archive's signed package lists, so no CA certificates are borrowed from the Go image. The README, `SPEC.md` and `docs/image.md` say so; the amd64-only limit, which came from the removed hash check, is gone.

Rule cut against: `REPO_POLICIES.md` pins every external reference by hash; sneak's ruling at sneak/prompts#72 (comment) allows Ubuntu's own package index.

Model: deepseek-v4-pro (implementation), opus-5-5 (rework)
2026-11-06 20:14:18 +01:00
clawbot a63b7e4d2d Rename internal/proxy to internal/servewaf (closes #173)
check / check (push) Successful in 3m44s
The package passes nothing on since the one-process mode went: it answers rproxy's questions, takes its reports and answers servewaf's own endpoints. It is now named after the command it serves, with `git mv`, and its constructor is `servewaf.New`. `SPEC.md` and the other documents follow. No change of behaviour.

Model: opus-5-5
2026-11-06 20:02:17 +01:00
clawbot 3cb41f27a8 deploy/gitea: Gitea behind traefik, rproxy and servewaf, with its test (closes #178)
check / check (push) Successful in 3m50s
A compose setup that runs gitea 1.27.3 behind traefik, rproxy and servewaf on one shared network with IPv4 and IPv6, and `script/gitea-setup` (`make gitea-setup`), which brings it up and checks that a page load, a push and a clone pass through rproxy and are checked by servewaf. Containers on the shared network skip servewaf's checks; the gateways do not. gitea gets a secret key made per install and has sign-up off. `docs/gitea.md` explains each setting.

Judgement call: the Core Rule Set runs in `detect` mode here until #198 lands.

Model: deepseek-v4-pro (implementation), opus-5-5 (rework)
2026-11-06 19:46:38 +01:00
clawbot 84a9190d2b next into main
check / check (push) Successful in 4m1s
Merged by sneak's order of 6 November ("take the current state of smallwebwaf and get it on to main. i am going to deploy it soon in its alpha state").

Model: opus-5-5
2026-11-06 18:58:02 +01:00
clawbot 6deb9d7581 README: sections, compose examples, env var table (closes #188) (#189)
check / check (push) Successful in 4m40s
2026-11-06 00:06:25 +01:00
clawbot 8632da556a rproxy tests give a loaded host time before their limits run out (closes #185) (#186)
check / check (push) Successful in 6m15s
2026-11-05 23:17:16 +01:00
clawbot 344ce13c96 rproxy and servewaf replace the one-process smallwebwaf (closes #172, closes #165)
check / check (push) Failing after 14m44s
`smallwebwaf` with no command now prints the commands and exits with status 2.
The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The
passing-on code, its settings and its log fields and metric go. internal/proxy
keeps the checks, answers, reports and own endpoints; its tests run through the
real rproxy. The documents describe the pair.

Judgement call: clients.json keeps token_refused, since state files refuse unknown fields.
Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests.
Partially verified: make example-app and make gitea-requests never built the image, as Ubuntu's snapshot service answered 5xx.

Model: opus-5-5
2026-11-05 13:15:05 +01:00
clawbot 17c5dd2438 rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Successful in 7m25s
`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf over
HTTP/1.1, and passes what servewaf allows to the app within its limits. Any
other answer goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
decides only when servewaf cannot be connected to, does not answer at all, or
does not answer within SWRP_TIMEOUT_MS. A method SPEC.md does not list gets 405.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 and G705; gosec at
internal/smallwebwaf/rproxy_test.go:431; tagliatelle at
internal/requestlog/requestlog.go:203; paralleltest on one test.
Judgement call: an answer over 1 MiB, or with a status below 200, gets 502.
Judgement call: every target not starting with one / gets 400.

Model: opus-5-5
2026-11-05 06:32:08 +01:00
clawbot 4a4874ae2f servewaf always answers rproxy well within its time limit (closes #182)
check / check (push) Successful in 8m31s
A question waits on GeoJS at most 800ms, the new default of
SWWAF_LOOKUP_TIMEOUT. In enforce mode it does not wait at all when it is
over the header limits, on a fetched blocklist with deny, on the CrowdSec
decision list, or over a whole rate limit. Not waiting never lowers a
client's limit. Just before the Core Rule Set, servewaf refuses more than
50 cookies or 100 header fields with 431.

Shared default changed: SWWAF_LOOKUP_TIMEOUT also applies to smallwebwaf
with no command.
Judgement call: SWWAF_REPUTATION_TIMEOUT stays 2s, since no request waits
on the DNS blocklists or AbuseIPDB.

Model: opus-5-5
2026-11-05 05:48:50 +01:00
clawbot 2eeea136e8 servewaf answers rproxy's question about each request and takes its report (closes #170)
check / check (push) Failing after 14m16s
`smallwebwaf servewaf` runs today's checks on the visitor's request each
question carries, answers rproxy, and passes nothing to an app. rproxy reports
every request servewaf answered. A report counts an allowed request's bytes and
writes servewaf's second log line. The table holds a copy of what a report
needs, never the request, so a report cannot reach a request being handled.

Judgement call: a question's body is read before any check; over 64 KiB gets 400.
Judgement call: a report's bytes and durations have ceilings, so they cannot overflow.
Judgement call: a `close` disposition for SWWAF_BAN_RESPONSE=close.

Model: opus-5-5
2026-11-05 00:32:08 +01:00
clawbot 718a2c94b2 Default request limits per minute, hour and day, sized for API use (closes #176)
check / check (push) Failing after 17m45s
The default request limits now suit programmatic use of an API, such
as an automation fleet on another host, and still catch abusive bots:

- per minute: 500, room for a fleet's busiest minute and a few first
  visits with an empty cache, and passed within seconds by a client
  firing as fast as it can;
- per hour: 20000, passed by a client that keeps up 6 a second;
- per day: 300000, passed by a scraper that keeps up 4 a second.

The byte limits and ban lengths are unchanged. SPEC.md, README.md and
docs/settings.md give the reasons; docs/gitea.md redoes the Actions
runner sums.

Judgement call: the docs/alerts.md ban example shows the minute limit.

Model: opus-5-5
2026-11-04 23:02:39 +01:00
sneak 1bdf66cd21 Merge pull request 'next into main' (#160) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #160
2026-10-26 13:11:04 +01:00
128 changed files with 13650 additions and 8399 deletions

No files matched your search

+35 -41
View File
@@ -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,12 +117,27 @@ 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
# The image's own labels, in place of the ones it would inherit from
# Ubuntu's. script/docker and script/cibuild pass the build arguments;
# a build that passes none labels the image unknown rather than leave
# Ubuntu's values.
ARG VERSION=unknown
ARG CREATED=unknown
ARG REVISION=unknown
LABEL org.opencontainers.image.title="smallwebwaf" \
org.opencontainers.image.description="A web application firewall for self-hosted web apps: sww rproxy in front of an app, and sww discriminator, which decides." \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${CREATED}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.source="https://git.eeqj.de/sneak/smallwebwaf"
# runit's install creates its _runit-log user with minsysusers, which # runit's install creates its _runit-log user with minsysusers, which
# reads this file in place of runit's /usr/lib/sysusers.d/runit.conf. # reads this file in place of runit's /usr/lib/sysusers.d/runit.conf.
# runit's line leaves out the shell, and minsysusers prints a Perl # runit's line leaves out the shell, and minsysusers prints a Perl
@@ -132,32 +147,11 @@ 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 RUN apt-get update \
# of the amd64 archive: other architectures use Ubuntu's ports archive.
# apt also fetches the live archive's InRelease files, which change daily
# and which the install does not use. The snapshot service is HTTPS only
# and this image has no CA certificates yet, so this step uses the Go
# image's. The snapshot service answers 503 at times; apt retries a
# failed download, waiting twice as long before each retry, and this
# step gives it five retries, about half a minute in all, where its
# 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/*
@@ -186,27 +180,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 +208,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
View File
@@ -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.
+2 -6
View File
@@ -1,12 +1,11 @@
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \ .PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
run example-app gitea-requests 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.
# reports the gitea requests the Core Rule Set refuses at the defaults.
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -46,6 +45,3 @@ run:
example-app: example-app:
@script/example-app @script/example-app
gitea-requests:
@script/gitea-requests
+564 -227
View File
@@ -1,42 +1,48 @@
# smallwebwaf # smallwebwaf
`smallwebwaf` is an MIT-licensed web application firewall, written in Go by `smallwebwaf` is an MIT-licensed web application firewall, written in Go by
[@sneak](https://sneak.berlin), for one self-hosted web app. It runs inside the [@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the
app's own container, in front of the app. It refuses the requests of scrapers, requests of scrapers, scanners and other abusers before they reach an app.
scanners and other abusers before they reach the app.
## What it does
A web application firewall reads each request on its way to a web app and A web application firewall reads each request on its way to a web app and
refuses the ones that look like abuse. `smallwebwaf` is also a reverse proxy: it refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
accepts requests on the app's behalf, passes them to the app, and passes the shipped in one image, with two commands that do this together:
app's answers back.
It needs no setting. Every setting has a default chosen for a service on the - `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
open internet. [`SPEC.md`](SPEC.md) says how `smallwebwaf` behaves and why. The it. It asks `discriminator` about each request, passes the allowed ones to the
reference documents under [`docs/`](docs/) are listed under "Documents" below. app, and passes the app's answers back. It keeps nothing and never touches
disk.
- `sww discriminator` decides. It runs the checks and keeps the bans, the
counters and each client's history. One `discriminator` serves many sites: a
client banned through one site is refused on all of them, and its requests on
all of them count toward one set of limits.
## What it protects against The site keeps answering while `discriminator` is down, slow or restarting:
without an answer in time, `rproxy` passes the request to the app.
- Scrapers that fetch every page of a site, all day. Rate limiting gives each It protects against:
client 6000 requests a minute, 100,000 an hour and 1,000,000 a day. Byte
limits give it 4 GiB a minute, 20 GiB an hour and 200 GiB a day. A client over - Scrapers that fetch every page of a site, all day. Each client gets a limit on
a limit is refused and banned. The first ban lasts an hour. Each repeat within requests and on bytes per minute, per hour and per day. The defaults leave
a day makes the next ban three times as long. A ban that would last more than room for a person reading a site for hours. They catch a scraper that keeps up
seven days is permanent. one request every 2 seconds. A client over a limit is refused and banned: for
a while at first, for three times as long at each repeat, and permanently when
it keeps at it.
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The - Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
default rule file bans such a client for seven days at its first probe. Its default rule file bans such a client at its first probe, and its next request
next request during the ban makes the ban permanent. The default rule file during the ban makes the ban permanent. Your own rules, or a list of trap
does not cover probes for other apps, such as a WordPress login. A `ban` rule paths, ban probes for other apps, such as a WordPress login, the same way.
of your own, or an entry in `SWWAF_TRAP_PATHS`, bans those the same way.
- Attacks carried in a request's URL or headers, such as SQL injection and path - Attacks carried in a request's URL or headers, such as SQL injection and path
traversal. The OWASP Core Rule Set is a published set of rules that recognise traversal. The OWASP Core Rule Set, a published set of rules that recognise
common web attacks. Coraza, a Go engine, runs those rules (see "Attack common web attacks, runs on every request through Coraza, a Go engine. A
detection" in [`SPEC.md`](SPEC.md)). A request the rules take for an attack is request the rules take for an attack is refused.
refused.
- Abuse from networks and countries you do not want. You can: - Abuse from networks and countries you do not want. You can:
- refuse whole netblocks and countries; - refuse whole netblocks and countries;
- give the clients of chosen networks and countries a fraction of every - give the clients of chosen networks and countries a fraction of every
limit, so that they are banned sooner; limit, so that they are banned sooner;
- refuse or limit clients on a blocklist or a DNS blocklist, or with a high - refuse or limit clients on a blocklist, a DNS blocklist, or with a high
AbuseIPDB score; AbuseIPDB score;
- ban clients named by your own CrowdSec engine. - ban clients named by your own CrowdSec engine.
- Slow and oversized requests. Every request and every answer has a time limit - Slow and oversized requests. Every request and every answer has a time limit
@@ -44,29 +50,26 @@ reference documents under [`docs/`](docs/) are listed under "Documents" below.
For the operator, it: For the operator, it:
- writes one JSON log line for every request, saying what it did and why (see - logs each request as JSON lines, saying what it did and why;
[`docs/request-log.md`](docs/request-log.md)); - sends alerts to a webhook, Slack or ntfy;
- sends alerts to a webhook, Slack or ntfy (see - serves Prometheus metrics;
[`docs/alerts.md`](docs/alerts.md));
- serves Prometheus metrics (see [`docs/metrics.md`](docs/metrics.md));
- keeps its bans, counters and history in JSON files you can read and edit, so - keeps its bans, counters and history in JSON files you can read and edit, so
that a restart loses nothing and a ban can be lifted with an editor (see that a restart loses nothing and a ban can be lifted with an editor.
[`docs/state-files.md`](docs/state-files.md)).
Not built, and not planned: Not built, and not planned:
- TLS and routing for several apps. Both stay with traefik, the reverse proxy in - TLS and routing by host name. Both stay with traefik, the reverse proxy in
front of the container. front of `rproxy`.
- Browser challenges such as captchas. - Browser challenges such as captchas.
- A web console. - A web console, or a configuration file.
- Defence against floods large enough to fill the host's network link. - Defence against floods large enough to fill the host's network link.
## Getting started ## Getting started
Build the image from a clone. `make docker` runs the tests and the linter, then ### The image
builds the image, tagged `smallwebwaf`. It builds for amd64, on an amd64 host
only: the `Dockerfile` checks Ubuntu's package lists against the hashes of the No image is published. Build it from a clone. `make docker` runs the tests and
amd64 archive. the linter, then builds the image, tagged `smallwebwaf`.
```sh ```sh
git clone https://git.eeqj.de/sneak/smallwebwaf.git git clone https://git.eeqj.de/sneak/smallwebwaf.git
@@ -74,14 +77,44 @@ cd smallwebwaf
make docker make docker
``` ```
Push the image to a registry your hosts pull from. Then build the app's own Push the image to a registry your hosts pull from, and name it there by digest.
image on it. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
The image runs `rproxy` by default. To run `discriminator` from the same image,
set the container's `SWW_COMMAND` to `discriminator`. The image's health check
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
the app accepts connections, whether or not `discriminator` answers; in
`discriminator`'s container, once `discriminator` answers.
### The usual setup: three containers
The usual setup is three containers on one private network:
- the app;
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
`/var/lib/sww` for its state files.
Point traefik's labels at port 8080 of `rproxy`
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
of the app or of `discriminator`. Anything on that network can reach the app
around `rproxy`, and can ask `discriminator` questions, so the network holds
only what you trust. Each further app adds its own container and its own
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
setup" example below shows this shape.
### `rproxy` in the app's container
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
runs beside the app in one container. `discriminator` still runs in a container
of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
by digest, and adds: by digest, and adds:
- the app's binary; - the app's binary;
- the packages the app needs, from the nixpkgs in the image; - the packages the app needs, from the nixpkgs in the image;
- a user for the app; - a user for the app;
- a `run` script that starts the app listening on `127.0.0.1:8081`. - a `run` script that starts the app listening on `127.0.0.1:8081`, the default
of `SWRP_UPSTREAM_URL`.
```dockerfile ```dockerfile
# The smallwebwaf image, pinned by digest. # The smallwebwaf image, pinned by digest.
@@ -116,117 +149,489 @@ main "$@"
``` ```
Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own Leave `ENTRYPOINT` and `USER` out of the app's `Dockerfile`. The image's own
entrypoint starts `smallwebwaf` and the app side by side, each as its own user. entrypoint starts `rproxy` and the app side by side, each as its own user. Run
the app's image with `SWRP_WAF_BACKEND` set. `deploy/example-app` is such an
app, and the "`rproxy` beside the app" example below runs one.
Deploy the app's image as you deploy any app. Point traefik's labels at port The app sees the headers traefik sent, with traefik's address added to
8080 (`traefik.http.services.<name>.loadbalancer.server.port=8080`). Mount a `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
volume at `/var/lib/smallwebwaf`, so that bans and history survive a deploy. For `rproxy`'s address for forwarded headers. In a container of its own, `rproxy` is
example, with no setting: on the private network, which the usual default of a trusted-proxy setting
covers. Beside the app, its address is `127.0.0.1` or `::1`, which the private
ranges leave out, so the app's own setting must name them too, as `app.run`
does.
```sh ### Checking that it works
docker run --detach --name app \
--volume app-smallwebwaf:/var/lib/smallwebwaf \ - `docker inspect --format '{{.State.Health.Status}}' <container>` says
<registry>/app@sha256:<digest> `healthy` for each container of `smallwebwaf`.
- A log line with `"msg":"starting"` gives the version, the address and every
setting in effect, with secrets masked.
- A request to the site through traefik gets the app's answer.
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
the request have `"action":"forward"`.
- To see a refusal without banning yourself, run `discriminator` with
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
`discriminator`'s log lines have `"would_action":"banned"` and
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
"Clients and bans" below describes.
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
`discriminator`'s network:
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
- `make example-app` builds the image and an app on it, runs them, and checks
the whole exchange (see "Entrypoints").
## Configuration
Every setting is an environment variable, read once at start; changing one means
restarting the container. `rproxy` reads only the `SWRP_` settings, and
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
default, chosen for a service on the open internet. A setting that needs an
account, a key, a token or an alert destination stays off until you give one.
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
contents are the value, less one newline at the end. Setting both forms stops
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
one, since `rproxy` never touches disk.
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
1024: `512K`, `100M`, `5G`.
- A count is a whole number of requests, such as `1000`.
- A time in milliseconds is a whole number above zero, such as `2000`.
- A list is comma separated. A list set to an empty value is an empty list, and
replaces the default.
- A netblock is an address and a prefix length, such as `10.0.0.0/8`; a bare
address stands for itself alone. A country is a two-letter code in either
case, such as `de`. An AS number is `AS` and the number, such as `AS64496`. A
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
- `off` switches a limit, a threshold or a timeout off where the table says a
setting takes it.
- An invalid setting stops the process at start with one log line naming the
variable and what is wrong with the value. The settings in effect are logged
on one line at start, with tokens, keys and webhook paths shown as `********`.
Every setting the code reads, with its default:
| Variable | Default | What it does |
| ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
| `SWW_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
| `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
| `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_ASN_LIMIT_PERCENT`. |
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. |
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
full, and the limits that are fixed rather than settings.
## docker-compose examples
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
`make docker` gives the image; on another host, use the name you pushed it
under, by digest. Docker Compose puts the services on a private network of its
own, where each reaches the others by service name. Each example publishes
`rproxy`'s port on the host, for trying it out.
### Smallest setup
One app, `rproxy` in front of it, and `discriminator` with a volume for its
state files, every setting at its default.
```yaml
services:
app:
# Your app, which listens on port 8081.
image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
rproxy:
image: smallwebwaf
environment:
SWRP_WAF_BACKEND: http://discriminator:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
discriminator:
image: smallwebwaf
environment:
SWW_COMMAND: discriminator
volumes:
- discriminator-state:/var/lib/sww
volumes:
discriminator-state:
``` ```
With no setting, the address of every new visitor is sent to GeoJS, a web ### Custom per-address limits
service, to find its country and network. `SWWAF_LOOKUP_SOURCE=off` sends
nothing. `SWWAF_LOOKUP_SOURCE=file`, with `SWWAF_LOOKUP_DB_PATH` naming a
database file on your own host, looks addresses up in that file (see "Lookup of
AS number and country" in [`SPEC.md`](SPEC.md)).
`deploy/example-app` is such an app. `make example-app` builds it and checks it For a site with no API:
(see "Checking that it works" below). To run `smallwebwaf` outside the image
while working on it, see "Entrypoints" below.
## What happens to a request - tighter limits for everyone;
- no limits for the office and the automation host;
- no checks for monitoring;
- a refused netblock;
- a smaller share of the limits for two hosting networks and one country;
- trap paths;
- `429` for a refused client.
`smallwebwaf` handles each request in fifteen steps, in this order. Once a step ```yaml
refuses a request, the steps after it are skipped, apart from step 15, and services:
nothing of the request reaches the app. "What happens to a request" in app:
[`SPEC.md`](SPEC.md) gives each step in full, under the same numbers. image: registry.example/app@sha256:0000000000000000000000000000000000000000000000000000000000000000
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once. rproxy:
2. The client is identified. image: smallwebwaf
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`. environment:
SWRP_WAF_BACKEND: http://discriminator:8080
SWRP_UPSTREAM_URL: http://app:8081
ports:
- "8080:8080"
discriminator:
image: smallwebwaf
environment:
SWW_COMMAND: discriminator
# Monitoring skips every check.
SWW_ALLOW_NETS: 192.0.2.10
# The office and the automation host are never rate limited.
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
# A netblock that sends nothing wanted.
SWW_DENY_NETS: 198.51.100.128/25
# Tighter limits for everyone else: a small static site.
SWW_RATE_LIMIT_PER_MINUTE: "60"
SWW_RATE_LIMIT_PER_HOUR: "600"
SWW_RATE_LIMIT_PER_DAY: "3000"
# Hosting networks and one country get a share of those limits.
SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
SWW_COUNTRY_LIMIT_PERCENT: cn:25
# Paths only scanners ask for; one request bans the client.
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
# Answer a refused client with 429 rather than 403.
SWW_BAN_RESPONSE: "429"
SWW_LIMIT_BAN_DURATION: 30m
volumes:
- discriminator-state:/var/lib/sww
volumes:
discriminator-state:
```
### `rproxy` beside the app
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
container". `rproxy` answers `503` to every request while `discriminator` is
down, and `discriminator`:
- observes before it enforces;
- looks addresses up in a local database rather than GeoJS;
- sends alerts to ntfy;
- reads its ntfy token from a file.
```yaml
services:
app:
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
build: ./app
environment:
SWRP_WAF_BACKEND: http://discriminator:8080
# Answer 503 rather than pass a request on while discriminator is down.
SWRP_DEFAULT_DISPOSITION: deny
ports:
- "8080:8080"
discriminator:
image: smallwebwaf
environment:
SWW_COMMAND: discriminator
SWW_INSTANCE_NAME: fsn1/discriminator
# Refuse nothing yet; log what enforce mode would have done.
SWW_MODE: observe
# Look addresses up in a local IPinfo Lite database, never GeoJS.
SWW_LOOKUP_SOURCE: file
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
# Alerts to an ntfy topic, with its token read from a file.
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
volumes:
- discriminator-state:/var/lib/sww
- /srv/ipinfo:/var/lib/ipinfo:ro
- /srv/sww/tokens:/etc/sww/tokens:ro
volumes:
discriminator-state:
```
The database is mounted by its directory, not by file, so that a replaced
database is seen. A token file belongs to uid 65532, the `sww` user, with mode
`0400`. This one holds an access token the ntfy server issued, which
`ntfy token add <user>` prints there:
```sh
mkdir -p /srv/sww/tokens
# <token> is the access token that ntfy printed.
echo '<token>' > /srv/sww/tokens/ntfy
chown 65532:65532 /srv/sww/tokens/ntfy
chmod 0400 /srv/sww/tokens/ntfy
```
## Design
How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
### What happens to a request
A request goes through these steps, in this order. Once `discriminator` refuses
a request, the steps after that are skipped, apart from the report, and nothing
of the request reaches the app.
`rproxy` takes the first two:
1. The health check, `GET /_sww/healthz`, is answered at once.
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
the visitor sent it, with the first 64 KiB of its body.
`discriminator` takes the checks:
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
4. The bans. 4. The bans.
5. The lookup of the client's AS number and country. 5. The lookup of the client's AS number and country.
6. The country lists. 6. The country lists.
7. The reputation sources. 7. The reputation sources.
8. The client's limit percentage. 8. The client's limit percentage, from its AS number, its country or a
reputation hit.
9. The rate limits. 9. The rate limits.
10. The trap paths. 10. The trap paths.
11. The rule files. 11. The rule files.
12. The Core Rule Set. 12. The header limits, and then the Core Rule Set.
13. `smallwebwaf`'s own endpoints, under `/_smallwebwaf/`. 13. A path under `/_sww/` is answered `404`, so that the app never sees that
14. The request is passed to the app. prefix.
15. After the answer: the byte limits, the error burst and the client's history.
A refused visitor is answered with `SWWAF_BAN_RESPONSE`, `403` by default (see `rproxy` takes the rest, with `discriminator` counting at the end:
"Ban lengths, response and scope" in [`SPEC.md`](SPEC.md)). A `block` rule or a
Core Rule Set match answers `403`. A missing or wrong token gets `401`. A
request over a size or time limit gets the status for that limit, such as `413`
for a body too large (see "Size and time limits" in [`SPEC.md`](SPEC.md)).
## Settings you are likely to touch 14. The request is passed to the app, streaming.
15. After the answer, `rproxy` sends `discriminator` a report with the status
and the bytes each way. For an allowed request, `discriminator` counts the
bytes toward the byte limits and adds the request to the client's history.
Each setting is an environment variable of the app's container, such as A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
`docker run -e SWWAF_MODE=observe ...`, read at start. Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
[`docs/settings.md`](docs/settings.md) lists every setting, its default and the positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
values it takes. The ones most deployments touch: request over the header limits of step 12 is answered `431`, and bans nobody
either. `rproxy` itself answers `405` to a method it does not pass on, and the
status for a size or time limit, such as `413` for a body too large.
- `SWWAF_MODE=observe`: `smallwebwaf` makes no ban of its own, and refuses a ### Clients and bans
request only for a size or time limit or a missing or wrong token. A request
it would have refused is passed to the app, and its log line says what
`enforce` mode would have done. For trying a configuration before enforcing
it.
- `SWWAF_ALLOW_NETS`: netblocks you trust, such as your monitoring. Their
clients are refused only for a size or time limit or a missing or wrong token.
- `SWWAF_RATE_LIMIT_EXEMPT_NETS`: netblocks exempt from rate limiting and the
byte limits, such as a machine that talks to the app all day.
- `SWWAF_TRAP_PATHS`: paths only scanners ask for, such as
`/wp-login.php,/xmlrpc.php` in front of a code forge. A request for one bans
the client.
- `SWWAF_WAF_DISABLED_RULES`: Core Rule Set rule ids to switch off when a rule
refuses your app's ordinary requests. The log line names the rules a request
matched. Keep the six default ids in a list you give (see
[`docs/settings.md`](docs/settings.md)).
- `SWWAF_REQUEST_MAX_BYTES`, `SWWAF_CLIENT_REQUEST_TIMEOUT` and
`SWWAF_UPSTREAM_REQUEST_TIMEOUT`: raise them for an app that takes uploads
over 100 MiB or slower than 60 seconds.
- `SWWAF_LOOKUP_SOURCE`: `geojs` (the default), `file` or `off`, as above.
- `SWWAF_ADMIN_TOKEN` and `SWWAF_METRICS_TOKEN`: switch on the ban endpoints and
the metrics. Give them as files to keep them from the app.
- `SWWAF_ALERT_WEBHOOK_URL`, `SWWAF_ALERT_SLACK_WEBHOOK_URL` or
`SWWAF_ALERT_NTFY_URL`: where alerts go.
- `SWWAF_INSTANCE_NAME`: the name in every log line, alert and metric, such as
`fsn1app1/gitea`.
## Checking that it works - A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
since one abuser usually controls a whole /64.
- Each limit is counted over a sliding window with a few integers per client.
The table of clients and the table of bans each have a bound
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
- A ban refuses every request from its netblock until it ends. Requests refused
under a ban count toward no limit. A permanent ban ends only when an admin
lifts it.
- Every ban carries notes: the client's AS number and country, the limit it
broke or the rule it matched, the request that caused it, and its earlier
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
- `docker inspect --format '{{.State.Health.Status}}' <container>` says ### Attack detection
`healthy` once `smallwebwaf` answers and the app accepts connections. A log
line with `"msg":"starting"` gives the version, the address and every setting - The Core Rule Set reads the method, the URL with its query string, and the
in effect, with secrets masked. headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
- A request to the site through traefik gets the app's answer, and its log line front of a code forge, bodies carry what people write and publish, which the
has `"action":"forward"`. `https://<site>/_smallwebwaf/healthz` answers `ok`. rules take for attacks.
- To see a refusal without banning yourself, run the container with - `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app. Its log requests to a code forge through, such as git over HTTP and sign-in through
line has `"would_action":"banned"` and `"rule_ids":["env-file"]`. In `enforce` Git Credential Manager. On gitea's own paths, its searches, file names and
mode the same request bans your address for seven days, and your next request branch names get through too. No setting undoes the changes; "Attack
makes the ban permanent. Lift the ban by editing `bans.json` (see "Bans" in detection" in [`SPEC.md`](SPEC.md) lists them.
[`SPEC.md`](SPEC.md)). - `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
- With `SWWAF_METRICS_TOKEN` set, fields or paths.
`curl -H "Authorization: Bearer $TOKEN" https://<site>/_smallwebwaf/metrics` - Rule files are plain text, one rule to a line, edited by hand and read again
answers with the metrics. while `discriminator` runs. The image ships one file of probes no real visitor
- `make example-app` builds the image and the example app on it, runs the app, sends (see [`docs/rules.md`](docs/rules.md)).
and checks it. It needs network access. It checks:
- the health check; ### State files
- a request forwarded to the app;
- a ban for a broken limit, and a ban for a probe made permanent; `discriminator` keeps everything in memory, and the five JSON files in
- orderly stops with `sv stop` and `docker stop`; `SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
- bans surviving a new container on the same volume; `reputation.json` and `alerts.json`. Each is written whole, to a temporary file
- a setting given as a file. renamed over the real one, so a crash leaves a complete file. `discriminator`
notices a file edited on disk and takes the edit in; an edit that does not parse
is set aside as `<name>.bad`, and never stops the running process (see
[`docs/state-files.md`](docs/state-files.md)).
### The image and its processes
- The image is built on Ubuntu 26.04 LTS, pinned by digest. Its Ubuntu packages
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
same package each time it is built (see [`docs/image.md`](docs/image.md)).
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
and starts it again when it exits. The `run` script of `discriminator` gives
the state directory to the `sww` user at each start, so a volume needs no
change of owner.
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own
endpoints, under `/_sww/`, are for your admin tools and your metrics
collector, at its own address on the private network; nothing routes the
internet there.
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
in the app's container, the app must keep answering for five seconds after
`SIGTERM` for that to help them.
- Only faults found at start stop a process: an invalid setting, a rule file or
state file that does not parse, a lookup database that cannot be read, or an
address it cannot listen on. Once running, a failing lookup service,
reputation source or alert destination never takes `discriminator` down.
### Packages
- `cmd/sww`: the binary, which only calls `internal/sww`.
- `internal/sww`: the process, as the command it is given, and the health check.
- `internal/config`: reads the settings, the one place they are read.
- `internal/rproxy`: the reverse proxy in front of one app.
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
answers its own endpoints.
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
`internal/alerts`: one check or source each.
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
`internal/metrics`: the state files, the log lines, the syslog sender and the
metrics.
## Rationale
Small self-hosted sites receive a great deal of traffic nobody asked for:
- scrapers that ignore `robots.txt` and crawl every commit of every repository
on a public git server;
- scanners walking through lists of WordPress and `.env` paths;
- credential-guessing bots.
Most of it comes from a small number of hosting networks and countries. A
single-person operation has no abuse desk and no CDN contract. It needs
something small that can be put in front of its services and left alone.
The existing tools each solve part of this. Rule-based firewalls catch attack
payloads but do not limit request rates. Rate limiters count requests but cannot
tell a residential visitor from a rented server farm. The products that do most
of it want a database and a web console. None of them can say "clients from
these networks are banned after half as many requests as anyone else".
[`EVALUATION.md`](EVALUATION.md) goes through the candidates one by one.
## Entrypoints ## Entrypoints
@@ -264,100 +669,34 @@ on the host.
image build. image build.
- `script/precommit`: run by the git pre-commit hook; runs `script/check`. - `script/precommit`: run by the git pre-commit hook; runs `script/check`.
- `script/install-precommit`: installs that hook. `make hooks`. - `script/install-precommit`: installs that hook. `make hooks`.
- `script/build`: builds `bin/smallwebwaf` with the Go on your host. - `script/build`: builds `bin/sww` with the Go on your host. `make build`.
`make build`. - `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
- `script/run`: builds `bin/smallwebwaf` and runs it with the settings in your host, with the settings in your environment. `make run`. Unless your settings
environment, listening on port 8080 in front of an app at say otherwise:
`SWWAF_UPSTREAM_URL`. Its state files go in `bin/state` and its rule files are - `rproxy` listens on port 8080, in front of an app at
those of `share/rules.d`, unless `SWWAF_STATE_DIR` and `SWWAF_RULES_DIR` say `http://127.0.0.1:8081`;
otherwise. `make run`. - `discriminator` listens on port 8090;
- `script/example-app`: builds the image and the example app on it, runs it with - its state files go in `bin/state`;
a volume, and checks it as "Checking that it works" above describes. Then it - its rule files are those of `share/rules.d`.
removes the containers, volumes and images. Needs network access. - `script/example-app`: builds the image and the example app
`make example-app`. (`deploy/example-app`) on it, runs the app with `rproxy` in its container and
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind it at `discriminator` in another, and checks them. Then it removes the containers,
the defaults. It sends through it a sample of gitea's ordinary use and the the network, the volumes and the images. Needs network access.
requests the Core Rule Set is known to refuse, and prints what `smallwebwaf` `make example-app`. It checks:
did with each. Needs network access, and a docker daemon that pushes to a - the health checks;
registry on `localhost` over plain HTTP. [`docs/gitea.md`](docs/gitea.md) - a request passed to the app;
records the results. `make gitea-requests`. - a ban for a broken limit, and a ban for a probe made permanent;
- that only `discriminator`'s container changes the state directory's owner;
## Rationale - with `discriminator` stopped, the app still answering through `rproxy`;
- with `discriminator` started again, bans applying again;
Small self-hosted sites receive a great deal of traffic nobody asked for: - orderly stops with `sv stop` and `docker stop`;
- bans surviving a new container on the same volume;
- scrapers that ignore `robots.txt` and crawl every commit of every repository - a setting given as a file.
on a public git server;
- scanners walking through lists of WordPress and `.env` paths;
- credential-guessing bots.
Most of it comes from a small number of hosting networks and countries. A
single-person operation has no abuse desk and no CDN contract. It needs
something small that can be put in front of one service and left alone.
The existing tools each solve part of this. Rule-based firewalls catch attack
payloads but do not limit request rates. Rate limiters count requests but cannot
tell a residential visitor from a rented server farm. The products that do most
of it want several containers, a database and a web console. None of them can
say "clients from these networks are banned after half as many requests as
anyone else". That is the useful thing to say when nearly all abuse comes from a
known list of AS numbers. [`EVALUATION.md`](EVALUATION.md) goes through the
candidates one by one.
`smallwebwaf` fills that gap with:
- per-client limits that ban;
- lower limits for the networks and countries abuse comes from;
- bans that grow with repetition;
- a log that is easy to search and ship;
- one binary in the app's own container, with no database and no required
setting.
## Design
The `smallwebwaf` image is built on Ubuntu 26.04 LTS, pinned by digest, with
nixpkgs fixed at one release. An app's `Dockerfile` installs a package with
`nix-env -iA nixpkgs.<name>` and gets the same package each time it is built.
[`docs/image.md`](docs/image.md) lists what the image holds and how each part is
pinned.
- runit, a service supervisor, runs `smallwebwaf` and the app side by side, each
as its own user, and starts either again when it exits. The app cannot touch
the state files or the process of `smallwebwaf`.
- `smallwebwaf` listens on port 8080 and passes requests to the app on
`127.0.0.1:8081`. The app listens on `127.0.0.1` only, so nothing reaches it
except through `smallwebwaf`. `smallwebwaf`'s own endpoints are under
`/_smallwebwaf/`, on the same port.
- The app sees the headers traefik sent, with traefik's address added to
`X-Forwarded-For`, from a TCP peer on `127.0.0.1`. So the app must trust
`127.0.0.1` and `::1` for forwarded headers, besides the private address
ranges.
- Both processes start with the container's environment variables, so every
setting of `smallwebwaf` starts with `SWWAF_`, and the app can read a token
given as one. A token given as a file that only the `smallwebwaf` user can
read is out of its reach (see "Settings given as files" in
[`docs/settings.md`](docs/settings.md)).
- The state files live in `/var/lib/smallwebwaf`. A volume mounted there keeps
them across a deploy.
- `smallwebwaf servewaf` runs the same checks for the sites that ask it about
each request, and passes nothing to an app (see "`servewaf`" in
[`SPEC.md`](SPEC.md)).
- `docker stop` stops both processes at once. `smallwebwaf` gives the requests
in progress five seconds to finish, so the app must keep answering for five
seconds after `SIGTERM`.
"Deployment" in [`SPEC.md`](SPEC.md) has the rest: the health check, what
happens when a process stops, moving `smallwebwaf` to another port, and the
forwarded headers. "Architecture" in [`SPEC.md`](SPEC.md) lists the packages and
what each does.
## TODO ## TODO
- Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes: - Planned, as "Planned, not built" in [`SPEC.md`](SPEC.md) describes: sending
- `smallwebwaf rproxy`, the reverse proxy in front of each app that asks the log over RELP.
`smallwebwaf servewaf` about each request;
- sharing bans between the instances of several apps;
- sending the log over RELP.
## Documents ## Documents
@@ -365,11 +704,10 @@ what each does.
- [`EVALUATION.md`](EVALUATION.md): what already exists, what each tool covers - [`EVALUATION.md`](EVALUATION.md): what already exists, what each tool covers
and misses, and why none was adopted. and misses, and why none was adopted.
- [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows. - [`REPO_POLICIES.md`](REPO_POLICIES.md): the policies this repository follows.
- [`docs/settings.md`](docs/settings.md): every setting, its default and the - [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
values it takes.
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file. - [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under - [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
`/_smallwebwaf/`, with examples. `/_sww/`, with examples.
- [`docs/request-log.md`](docs/request-log.md): the request log's fields. - [`docs/request-log.md`](docs/request-log.md): the request log's fields.
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination - [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
gets. gets.
@@ -377,7 +715,6 @@ what each does.
- [`docs/state-files.md`](docs/state-files.md): the state files and what each - [`docs/state-files.md`](docs/state-files.md): the state files and what each
holds. holds.
- [`docs/image.md`](docs/image.md): what the image holds and how it is pinned. - [`docs/image.md`](docs/image.md): what the image holds and how it is pinned.
- [`docs/gitea.md`](docs/gitea.md): `smallwebwaf` in front of gitea.
## License ## License
+1027 -623
View File
File diff suppressed because it is too large. Load diff
-18
View File
@@ -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))
}
+20
View File
@@ -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 -2
View File
@@ -6,8 +6,12 @@
# 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}
# Its own title and description, in place of the smallwebwaf image's.
LABEL org.opencontainers.image.title="smallwebwaf-example-app" \
org.opencontainers.image.description="An example app built on the smallwebwaf 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 -24
View File
@@ -1,49 +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. `smallwebwaf servewaf` answers them at its own "Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
address, without those checks (see "`servewaf`" in [`SPEC.md`](../SPEC.md)).
- `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
``` ```
+19 -18
View File
@@ -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 6000", "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": 6000, "limit": 500,
"window": "minute", "window": "minute",
"count": 6001, "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": 8210, "requests": 501,
"refused": 0, "refused": 0,
"earlier_bans": { "earlier_bans": {
"limit": 0, "limit": 0,
@@ -76,10 +77,10 @@ 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. Only `servewaf` sends - `host`: the site of the request that raised the alert, since one
this field, since it serves many sites, and not for `source_failure`, `discriminator` serves many sites. It is left out of `source_failure`,
`file_error` and `summary`. `file_error` and `summary`.
- `client`: the address whose request raised the alert. Empty for - `client`: the address whose request raised the alert. Empty for
`source_failure`, `file_error` and `summary`. `source_failure`, `file_error` and `summary`.
@@ -92,7 +93,7 @@ on one line:
- `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 6000` 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));
@@ -110,8 +111,8 @@ on one line:
## Slack and ntfy ## Slack and ntfy
Slack and ntfy get a message with a title and a text. The title is the instance Slack and ntfy get a message with a title and a text. The title is the instance
and the event, such as `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 host, the client, the netblock and the country; - the host, the client, the netblock and the country;
- the file, the source, the error and the mode from `detail`; - the file, the source, the error and the mode from `detail`;
@@ -121,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:
-218
View File
@@ -1,218 +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 1 GiB unless the
repository is large or the link slow. A repository whose clone is larger than
1 GiB needs `SWWAF_RESPONSE_MAX_BYTES` raised, and the byte limits with it
("Byte limits, per client" in `SPEC.md`).
- 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. While it runs a job, it reports the job's log and state
every second instead, two requests a second. So one runner busy all day sends
172,800 requests, under the request limits. Six such runners on one address,
each busy all day, pass the day limit of 1,000,000 within two days; five stay
under it. Their address is then banned, their running jobs fail, and each
repeat within a day makes the ban longer. Newer runners ask for work less
often. Put the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`.
## What the Core Rule Set 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
View File
@@ -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 -58
View File
@@ -1,90 +1,84 @@
# The metrics # The metrics
What `GET /_smallwebwaf/metrics` answers with. "Metrics endpoint" in What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
[`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps the `instance` "Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
label as `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.
`smallwebwaf servewaf` counts a denied request at its decision. 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.
## 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.
+124 -98
View File
@@ -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,34 +79,32 @@ 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`, or for a reputation source with the
the action `deny`; action `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`.
@@ -83,7 +118,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 +128,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.
@@ -106,76 +143,65 @@ Every line has:
- `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.
## `servewaf`'s lines ### Requests to its own endpoints
`smallwebwaf servewaf` writes two lines for each question it answers, joined by The line of a request to `discriminator`'s own endpoints has these fields, with
`request_id`. The field `servewaf_line` says which of the two a line is: these meanings where they differ:
- `decision`: the line it writes once it has answered the question; - `client_ip` and `peer_ip`: the request's TCP peer.
- `report`: the line it writes when `rproxy`'s report comes, with everything - `request_id`: a new random one of 26 letters and digits.
known. The repetition is intended. - `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.
A request to its own endpoints writes one line, without `servewaf_line`. A ## `rproxy`'s line
report writes no line but the `report` line of the request it names.
These questions write no 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:
- a question refused with `400` (see "The question" under "`servewaf`" in - `type`: `request`.
[`SPEC.md`](../SPEC.md)); - `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
- a question whose body stopped arriving, which gets no answer. - `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.
### The `decision` line For example:
It has the fields above, with these meanings where they differ: ```
{"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"}
- `client_ip`: `X-Smallwebwaf-Client`. ```
- `peer_ip`: `X-Smallwebwaf-Peer`.
- `host`: the site the visitor asked for.
- `protocol`: the question's own, `HTTP/1.1`.
- `content_length`: `X-Smallwebwaf-Content-Length`.
- `request_id`: the question's `X-Request-ID`.
- `status`: what `servewaf` answered: `200` for `allow`, the status of `deny`,
or `0` for `close`, as the visitor is then sent nothing.
- `request_bytes`: how much of the question's body the Core Rule Set read.
- `response_bytes`: the body of `servewaf`'s answer.
- `action`: `forward` for `allow`, and `admin` for the `404` for a path under
`/_smallwebwaf/`.
- `aborted`: true when `rproxy` went away before the answer.
- `duration_total`: from the question's headers being read to the line.
The line leaves out `upstream_status`, `websocket` and the
`duration_upstream_...` fields, since `servewaf` passes nothing to the app.
The bytes of an allowed request are counted when its report comes. So `counts`
holds the client's bytes without this request's.
### The `report` line
It has every field of the `decision` line, and `time` stays when the request
arrived. These fields say what the report says:
- `status`: what the visitor got, `0` if nothing;
- `request_bytes` and `response_bytes`: the body bytes each way, with what a
WebSocket carried each way;
- `duration_total`: from the request's headers being read by `rproxy` to the end
of the answer;
- `duration_upstream_total`: from the hand-over to the app to the end of the
answer, for an allowed request.
For an allowed request, the report's counting also changes these fields:
- `counts`: the byte totals now include this request's;
- `limit_hit`, `offence` and `ban_expires`: set when the report's bytes broke a
byte limit.
+11 -5
View File
@@ -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`.
+236 -172
View File
@@ -4,22 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
that take `off`, the `_FILE` form, and what each group does and why. that take `off`, the `_FILE` form, and what each group does and why.
## `servewaf` 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
`smallwebwaf servewaf` uses every setting here but these, which it ignores: between the two (see "The image's command" below).
- `SWWAF_UPSTREAM_URL` and `SWWAF_TRUSTED_PROXIES`;
- the five timeouts under "Size and time limits";
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`, `SWWAF_REQUEST_MAX_BYTES` and
`SWWAF_RESPONSE_MAX_BYTES`.
An invalid value of one of these still stops the start, and the line of settings
at start still shows them.
`SWWAF_ADD_LOOKUP_HEADERS` puts the headers on the answer to `rproxy`, not on a
request to an app. `SWWAF_WAF_BODY_LIMIT` has an effect up to 64 KiB less one
byte, whatever its value. A question carries no more of a body than 64 KiB (see
"`servewaf`" in [`SPEC.md`](../SPEC.md)).
## Forms of values ## Forms of values
@@ -41,251 +28,250 @@ byte, whatever its value. A question carries no more of a body than 64 KiB (see
## 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 (see step 3 in [`SPEC.md`](../SPEC.md)). (see step 3 in [`SPEC.md`](../SPEC.md)).
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are - `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
exempt from rate limiting and the byte limits. exempt from rate limiting and the byte limits.
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see - `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
step 3 in [`SPEC.md`](../SPEC.md)). 3 in [`SPEC.md`](../SPEC.md)).
## Request rate limits, per client ## Request rate limits, per client
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `6000`), `SWWAF_RATE_LIMIT_PER_HOUR` - `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
(default `100000`) and `SWWAF_RATE_LIMIT_PER_DAY` (default `1000000`): the client may make in a minute. A count or `off`. The default leaves room for a
most 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 `4G`), `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 `200G`): 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 `1G`): 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`. `off` stops - `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
the start while a setting that needs a lookup is set: the start while a setting that needs a lookup is set:
- the two country lists; - the two country lists;
- `SWWAF_ADD_LOOKUP_HEADERS`; - `SWW_ADD_LOOKUP_HEADERS`;
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`; - the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`;
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`; - `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
- an anomaly threshold per AS number. - an anomaly threshold per AS number.
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its - `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`. `.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
- `SWWAF_LOOKUP_TIMEOUT` (default `1s`): how long a request waits for its - `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
client's first answer from GeoJS, and how long a request to GeoJS may take. A client's first answer from GeoJS, and how long a request to GeoJS may take. A
duration; never `off`. duration; never `off`.
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` 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 lists
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused, - `SWW_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
such as `cn,ru,kp`. such as `cn,ru,kp`.
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only - `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
countries whose clients get through, such as `us,de`. A country on both lists countries whose clients get through, such as `us,de`. A country on both lists
stops the start. stops the start.
## Biased thresholds ## Biased thresholds
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:` - `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
listed twice is refused. listed twice is refused.
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as - `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
`cn:25,ru:50`. `cn:25,ru:50`.
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty): - `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): the
the same, for the byte limits alone. same, for the byte limits alone.
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the - `SWW_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`. 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 (see "Attack detection" in `SPEC.md`). A count or `off`. it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
exclusions, written as Coraza directives. It is loaded before the Core Rule
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
file that names a number from 900000 to 900999 outside a comment, alone or in
a range. So does a directive that removes or changes rules as the file loads,
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
yet. It has no `_FILE` form.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals 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
@@ -294,25 +280,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. - `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
@@ -331,8 +317,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 \
... ...
``` ```
@@ -345,4 +332,81 @@ A few limits are fixed rather than settings:
- at most 1,000 DNS blocklist queries are under way at once; - at most 1,000 DNS blocklist queries are under way at once;
- at most 20,000 anomaly counters are kept; - at most 20,000 anomaly counters are kept;
- each alert destination's queue holds at most 1,000 alerts; - each alert destination's queue holds at most 1,000 alerts;
- `servewaf` holds at most 10,000 answered requests awaiting their reports. - `discriminator` holds at most 10,000 answered requests awaiting their reports;
- `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
View File
@@ -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
+36 -36
View File
@@ -1,12 +1,12 @@
// Package alerts sends alerts on bans, on traffic over an anomaly // Package alerts sends alerts on bans, on traffic over an anomaly
// threshold, on a source that fails and on a file with an error to each // threshold, on a source that fails and on a file with an error to each
// destination set: to the webhook // destination set: to the webhook
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON // SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
// object" in docs/alerts.md describes, to the Slack incoming // object" in docs/alerts.md describes, to the Slack incoming
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the // webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within // ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past // SWW_ALERT_COOLDOWN is held back, and so is an alert past
// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a // SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
// bounded queue of each destination's own, so that a destination that is // bounded queue of each destination's own, so that a destination that is
// slow or unreachable holds up neither the others nor any request. The // slow or unreachable holds up neither the others nor any request. The
// state is written to alerts.json and read from it by the state package. // state is written to alerts.json and read from it by the state package.
@@ -34,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
@@ -151,7 +151,7 @@ type Params struct {
// Alert is one alert, as the webhook is sent it and alerts.json holds it, // Alert is one alert, as the webhook is sent it and alerts.json holds it,
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and // with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
// Country are, for a ban, the client's as the ban's notes give them. Host is // Country are, for a ban, the client's as the ban's notes give them. Host is
// the site of the request that raised the alert, while one servewaf serves // the site of the request that raised the alert, while one discriminator serves
// many sites, and "" otherwise. // many sites, and "" otherwise.
// //
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case //nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
@@ -301,17 +301,17 @@ func New(params Params) *Queue {
} }
if params.WebhookURL != nil { if params.WebhookURL != nil {
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL", q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
params.WebhookURL, q.webhookMessage) params.WebhookURL, q.webhookMessage)
} }
if params.SlackURL != nil { if params.SlackURL != nil {
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL", q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
params.SlackURL, slackMessage) params.SlackURL, slackMessage)
} }
if params.NtfyURL != nil { if params.NtfyURL != nil {
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL", q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
params.NtfyURL, q.ntfyMessage) params.NtfyURL, q.ntfyMessage)
} }
@@ -319,7 +319,7 @@ func New(params Params) *Queue {
} }
// Raise sends alert, which names its event and what is particular to it, // Raise sends alert, which names its event and what is particular to it,
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event // unless no destination is set or SWW_ALERT_EVENTS leaves its event
// out. It gives alert the instance and the time. An alert that repeats // out. It gives alert the instance and the time. An alert that repeats
// the last one let through less than Cooldown before is held back and // the last one let through less than Cooldown before is held back and
// counted. The next one let through gives that count, unless an hour of // counted. The next one let through gives that count, unless an hour of
@@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
} }
// WouldSend reports whether Raise would let an alert for event on // WouldSend reports whether Raise would let an alert for event on
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses // netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
// event, no alert for event on netblock was let through less than // event, no alert for event on netblock was let through less than
// Cooldown before, and fewer than MaxPerHour alerts have been let through // Cooldown before, and fewer than MaxPerHour alerts have been let through
// in the hour under way. Unlike Raise, it counts nothing. // in the hour under way. Unlike Raise, it counts nothing.
@@ -606,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 {
@@ -683,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),
+25 -25
View File
@@ -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",
}, },
@@ -1286,7 +1286,7 @@ func anAlertForEachEvent() []eventMessage {
Reason: "refused by the Core Rule Set", Reason: "refused by the Core Rule Set",
Host: "app.example", Host: "app.example",
}, },
"fsn1app1/gitea: waf_block", "default shield", "fsn1app1/blog: waf_block", "default shield",
"refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" + "refused by the Core Rule Set\nhost: app.example\nclient: 192.0.2.1\n" +
"netblock: 192.0.2.1/32", "netblock: 192.0.2.1/32",
}, },
@@ -1295,7 +1295,7 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"), Event: alerts.EventAnomaly, Netblock: netip.MustParsePrefix("192.0.2.0/24"),
Reason: "requests per minute over the threshold of 5000", Reason: "requests per minute over the threshold of 5000",
}, },
"fsn1app1/gitea: anomaly", "high chart_with_upwards_trend", "fsn1app1/blog: anomaly", "high chart_with_upwards_trend",
"requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24", "requests per minute over the threshold of 5000\nnetblock: 192.0.2.0/24",
}, },
{ {
@@ -1304,7 +1304,7 @@ func anAlertForEachEvent() []eventMessage {
Netblock: netip.MustParsePrefix("192.0.2.2/32"), Netblock: netip.MustParsePrefix("192.0.2.2/32"),
Reason: "listed by a DNS blocklist", Reason: "listed by a DNS blocklist",
}, },
"fsn1app1/gitea: reputation_hit", "low label", "fsn1app1/blog: reputation_hit", "low label",
"listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32", "listed by a DNS blocklist\nclient: 192.0.2.2\nnetblock: 192.0.2.2/32",
}, },
{ {
@@ -1312,7 +1312,7 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed", Event: alerts.EventSourceFailure, Reason: "asking GeoJS failed",
Detail: map[string]any{"source": "geojs"}, Detail: map[string]any{"source": "geojs"},
}, },
"fsn1app1/gitea: source_failure", "high warning", "fsn1app1/blog: source_failure", "high warning",
"asking GeoJS failed\nsource: geojs", "asking GeoJS failed\nsource: geojs",
}, },
{ {
@@ -1320,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage {
Event: alerts.EventFileError, Event: alerts.EventFileError,
Reason: "a rule file has an error, and the rules stay as they were", Reason: "a rule file has an error, and the rules stay as they were",
Detail: map[string]any{ Detail: map[string]any{
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action", "file": "/etc/sww/rules.d/50-app.rules", "error": "line 2: no action",
}, },
}, },
"fsn1app1/gitea: file_error", "high warning", "fsn1app1/blog: file_error", "high warning",
"a rule file has an error, and the rules stay as they were\n" + "a rule file has an error, and the rules stay as they were\n" +
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action", "file: /etc/sww/rules.d/50-app.rules\nerror: line 2: no action",
}, },
} }
} }
+12 -12
View File
@@ -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
@@ -107,13 +107,13 @@ type Request struct {
Host string Host string
// Client is the client's address, and ClientGroup the client it is // Client is the client's address, and ClientGroup the client it is
// counted as: its IPv4 address, or the IPv6 netblock of // counted as: its IPv4 address, or the IPv6 netblock of
// SWWAF_IPV6_GROUP_PREFIX its address is in. // SWW_IPV6_GROUP_PREFIX its address is in.
Client netip.Addr Client netip.Addr
ClientGroup netip.Prefix ClientGroup netip.Prefix
// ASN, ASName and Country are the client's as looked up, each "" when // ASN, ASName and Country are the client's as looked up, each "" when
// unknown. // unknown.
ASN, ASName, Country string ASN, ASName, Country string
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them. // Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
Bytes int64 Bytes int64
} }
+4 -4
View File
@@ -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
+9 -9
View File
@@ -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) {
+1 -1
View File
@@ -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{
+289 -325
View File
File diff suppressed because it is too large. Load diff
+177 -285
View File
@@ -23,108 +23,99 @@ import (
// The settings, by name. // The settings, by name.
const ( const (
listenAddr = "SWWAF_LISTEN_ADDR" listenAddr = "SWW_LISTEN_ADDR"
upstreamURL = "SWWAF_UPSTREAM_URL" mode = "SWW_MODE"
mode = "SWWAF_MODE" ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
trustedProxies = "SWWAF_TRUSTED_PROXIES" maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX" allowNets = "SWW_ALLOW_NETS"
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS" rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
clientRequestTimeout = "SWWAF_CLIENT_REQUEST_TIMEOUT" denyNets = "SWW_DENY_NETS"
clientHeaderMaxBytes = "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES" rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
clientIdleTimeout = "SWWAF_CLIENT_IDLE_TIMEOUT" rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
clientResponseTimeout = "SWWAF_CLIENT_RESPONSE_TIMEOUT" rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
upstreamRequestTimeout = "SWWAF_UPSTREAM_REQUEST_TIMEOUT" rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
upstreamResponseTimeout = "SWWAF_UPSTREAM_RESPONSE_TIMEOUT" bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
requestMaxBytes = "SWWAF_REQUEST_MAX_BYTES" bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
responseMaxBytes = "SWWAF_RESPONSE_MAX_BYTES" bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
allowNets = "SWWAF_ALLOW_NETS" bytesCount = "SWW_BYTES_COUNT"
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS" lookupSource = "SWW_LOOKUP_SOURCE"
denyNets = "SWWAF_DENY_NETS" lookupDBPath = "SWW_LOOKUP_DB_PATH"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" lookupTimeout = "SWW_LOOKUP_TIMEOUT"
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR" addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" deniedCountries = "SWW_DENIED_COUNTRIES"
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS" allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE" asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR" countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY" asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
bytesCount = "SWWAF_BYTES_COUNT" countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
lookupSource = "SWWAF_LOOKUP_SOURCE" unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
lookupDBPath = "SWWAF_LOOKUP_DB_PATH" asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT" blocklistURLs = "SWW_BLOCKLIST_URLS"
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS" blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
deniedCountries = "SWWAF_DENIED_COUNTRIES" blocklistAction = "SWW_BLOCKLIST_ACTION"
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES" dnsblZones = "SWW_DNSBL_ZONES"
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT" dnsblResolver = "SWW_DNSBL_RESOLVER"
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT" abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT" abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT" abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT" reputationAction = "SWW_REPUTATION_ACTION"
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL" reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
blocklistURLs = "SWWAF_BLOCKLIST_URLS" reputationTimeout = "SWW_REPUTATION_TIMEOUT"
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH" crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
blocklistAction = "SWWAF_BLOCKLIST_ACTION" crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
dnsblZones = "SWWAF_DNSBL_ZONES" banResponse = "SWW_BAN_RESPONSE"
dnsblResolver = "SWWAF_DNSBL_RESOLVER" limitBanDuration = "SWW_LIMIT_BAN_DURATION"
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY" limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE" maxBanDuration = "SWW_MAX_BAN_DURATION"
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET" attackBanDuration = "SWW_ATTACK_BAN_DURATION"
reputationAction = "SWWAF_REPUTATION_ACTION" maxBans = "SWW_MAX_BANS"
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL" banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT" stateDir = "SWW_STATE_DIR"
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL" stateWriteDelay = "SWW_STATE_WRITE_DELAY"
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY" stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
banResponse = "SWWAF_BAN_RESPONSE" adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION" metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW" metricsTopN = "SWW_METRICS_TOP_N"
maxBanDuration = "SWWAF_MAX_BAN_DURATION" instanceName = "SWW_INSTANCE_NAME"
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION" logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
maxBans = "SWWAF_MAX_BANS" logLevel = "SWW_LOG_LEVEL"
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX" rulesDir = "SWW_RULES_DIR"
stateDir = "SWWAF_STATE_DIR" rulesEnabled = "SWW_RULES_ENABLED"
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY" wafMode = "SWW_WAF_MODE"
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL" wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name wafDisabledRules = "SWW_WAF_DISABLED_RULES"
metricsTopN = "SWWAF_METRICS_TOP_N" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
instanceName = "SWWAF_INSTANCE_NAME" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS" exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
logLevel = "SWWAF_LOG_LEVEL" trapPaths = "SWW_TRAP_PATHS"
rulesDir = "SWWAF_RULES_DIR" errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
rulesEnabled = "SWWAF_RULES_ENABLED" logRemoteURL = "SWW_LOG_REMOTE_URL"
wafMode = "SWWAF_WAF_MODE" logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL" logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD" logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT" alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
trapPaths = "SWWAF_TRAP_PATHS" alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" alertNtfyURL = "SWW_ALERT_NTFY_URL"
logRemoteURL = "SWWAF_LOG_REMOTE_URL" alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE" alertEvents = "SWW_ALERT_EVENTS"
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER" alertCooldown = "SWW_ALERT_COOLDOWN"
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY" alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME" anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL" anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS" watchNets = "SWW_WATCH_NETS"
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL"
alertNtfyURL = "SWWAF_ALERT_NTFY_URL"
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
alertEvents = "SWWAF_ALERT_EVENTS"
alertCooldown = "SWWAF_ALERT_COOLDOWN"
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
watchNets = "SWWAF_WATCH_NETS"
) )
// The anomaly thresholds: each of the prefixes below, which name a scope, // The anomaly thresholds: each of the prefixes below, which name a scope,
// followed by each of the four ends. // followed by each of the four ends.
const ( const (
anomalyClient = "SWWAF_ANOMALY_CLIENT_" anomalyClient = "SWW_ANOMALY_CLIENT_"
anomalyNet = "SWWAF_ANOMALY_NET_" anomalyNet = "SWW_ANOMALY_NET_"
anomalyASN = "SWWAF_ANOMALY_ASN_" anomalyASN = "SWW_ANOMALY_ASN_"
anomalyTotal = "SWWAF_ANOMALY_TOTAL_" anomalyTotal = "SWW_ANOMALY_TOTAL_"
watch = "SWWAF_WATCH_" watch = "SWW_WATCH_"
requestsPerMinute = "REQUESTS_PER_MINUTE" requestsPerMinute = "REQUESTS_PER_MINUTE"
requestsPerHour = "REQUESTS_PER_HOUR" requestsPerHour = "REQUESTS_PER_HOUR"
@@ -165,21 +156,21 @@ func loggedAnomalyDefaults() map[string]string {
return logged return logged
} }
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and // defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN. // defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
const ( const (
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," + defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
"source_failure,file_error" "source_failure,file_error"
defaultAlertCooldown = "15m" defaultAlertCooldown = "15m"
) )
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL. // defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
const defaultReputationCacheTTL = "24h" const defaultReputationCacheTTL = "24h"
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES. // defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140" const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS. // defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," + const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
"content-type,origin,range" "content-type,origin,range"
@@ -204,10 +195,10 @@ const (
otherToken = "fedcba9876543210fedcba9876543210" otherToken = "fedcba9876543210fedcba9876543210"
) )
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and // instance is an SWW_INSTANCE_NAME that is a valid app name too, and
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines. // remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
const ( const (
instance = "fsn1app1/gitea" instance = "fsn1app1/blog"
remoteURL = "syslog+udp://192.0.2.1:514" remoteURL = "syslog+udp://192.0.2.1:514"
) )
@@ -217,7 +208,7 @@ const off = "off"
// enabled is true, as a setting's value. // enabled is true, as a setting's value.
const enabled = "true" const enabled = "true"
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and // defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
// fileSource the source that is the lookup database. // fileSource the source that is the lookup database.
const ( const (
defaultLookupSource = "geojs" defaultLookupSource = "geojs"
@@ -254,17 +245,9 @@ func TestDefaults(t *testing.T) {
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
ListenAddr: ":8080", ListenAddr: ":8080",
Observe: false, Observe: false,
ClientRequestTimeout: time.Minute, RateLimitPerMinute: 120,
ClientRequestHeaderMaxBytes: 32 << 10, RateLimitPerHour: 1500,
ClientIdleTimeout: 2 * time.Minute, RateLimitPerDay: 6000,
ClientResponseTimeout: 30 * time.Minute,
UpstreamRequestTimeout: time.Minute,
UpstreamResponseTimeout: 30 * time.Minute,
RequestMaxBytes: 100 << 20,
ResponseMaxBytes: 1 << 30,
RateLimitPerMinute: 6000,
RateLimitPerHour: 100000,
RateLimitPerDay: 1000000,
BanResponse: 403, BanResponse: 403,
LimitBanDuration: time.Hour, LimitBanDuration: time.Hour,
LimitBanRepeatWindow: 24 * time.Hour, LimitBanRepeatWindow: 24 * time.Hour,
@@ -272,28 +255,22 @@ func TestDefaults(t *testing.T) {
AttackBanDuration: 7 * 24 * time.Hour, AttackBanDuration: 7 * 24 * time.Hour,
MaxBans: 5000, MaxBans: 5000,
BanScopeV4Prefix: 32, BanScopeV4Prefix: 32,
StateDir: "/var/lib/smallwebwaf", StateDir: "/var/lib/sww",
StateWriteDelay: 10 * time.Second, StateWriteDelay: 10 * time.Second,
StateCounterInterval: 15 * time.Minute, StateCounterInterval: 15 * time.Minute,
MetricsToken: "", MetricsToken: "",
MetricsTopN: 50, MetricsTopN: 50,
RulesDir: "/etc/smallwebwaf/rules.d", RulesDir: "/etc/sww/rules.d",
RulesEnabled: true, RulesEnabled: true,
}) })
wantLookupSettings(t, cfg, config.Config{ wantLookupSettings(t, cfg, config.Config{
LookupSource: defaultLookupSource, LookupTimeout: time.Second, LookupSource: defaultLookupSource, LookupTimeout: 800 * time.Millisecond,
}) })
wantByteLimitSettings(t, cfg, config.Config{ wantByteLimitSettings(t, cfg, config.Config{
BytesLimitPerMinute: 4 << 30, BytesLimitPerHour: 20 << 30, BytesLimitPerMinute: 0, BytesLimitPerHour: 50 << 30,
BytesLimitPerDay: 200 << 30, BytesCount: "both", BytesLimitPerDay: 100 << 30, BytesCount: "both",
}) })
if cfg.UpstreamURL.String() != "http://127.0.0.1:8081" {
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
}
wantNetblocks(t, cfg.TrustedProxies,
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
wantNetblocks(t, cfg.AllowNets) wantNetblocks(t, cfg.AllowNets)
wantNetblocks(t, cfg.RateLimitExemptNets) wantNetblocks(t, cfg.RateLimitExemptNets)
wantNetblocks(t, cfg.DenyNets) wantNetblocks(t, cfg.DenyNets)
@@ -322,12 +299,17 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
cfg := fromEnvironment(t, environment{}) cfg := fromEnvironment(t, environment{})
rproxy, err := rproxyConfig(map[string]string{wafBackend: backendURL})
if err != nil {
t.Fatalf("settings: %v", err)
}
// The largest request body and the largest response, both counted. // The largest request body and the largest response, both counted.
largest := cfg.RequestMaxBytes + cfg.ResponseMaxBytes largest := rproxy.MaxRequestSize + rproxy.MaxResponseSize
for _, limit := range []int64{ for _, limit := range []int64{
cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay, cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.BytesLimitPerDay,
} { } {
if largest > limit { if limit != 0 && largest > limit { // 0 is off
t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit) t.Errorf("a request of %d bytes breaks the byte limit of %d", largest, limit)
} }
} }
@@ -338,17 +320,7 @@ func TestValuesAsSet(t *testing.T) {
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
listenAddr: "127.0.0.1:9000", listenAddr: "127.0.0.1:9000",
upstreamURL: "https://app.internal:8443/",
mode: "observe", mode: "observe",
trustedProxies: " 192.0.2.1, 10.1.2.3/8 ,2001:db8::/32",
clientRequestTimeout: "90s",
clientHeaderMaxBytes: "8K",
clientIdleTimeout: "5m",
clientResponseTimeout: "7d",
upstreamRequestTimeout: "1h30m",
upstreamResponseTimeout: off,
requestMaxBytes: "512K",
responseMaxBytes: "1234",
allowNets: "192.0.2.7", allowNets: "192.0.2.7",
rateLimitExemptNets: "2001:db8::/48, 10.9.8.7", rateLimitExemptNets: "2001:db8::/48, 10.9.8.7",
denyNets: "198.51.100.0/24", denyNets: "198.51.100.0/24",
@@ -376,14 +348,6 @@ func TestValuesAsSet(t *testing.T) {
wantSettings(t, cfg, config.Config{ wantSettings(t, cfg, config.Config{
ListenAddr: "127.0.0.1:9000", ListenAddr: "127.0.0.1:9000",
Observe: true, Observe: true,
ClientRequestTimeout: 90 * time.Second,
ClientRequestHeaderMaxBytes: 8 << 10,
ClientIdleTimeout: 5 * time.Minute,
ClientResponseTimeout: 7 * 24 * time.Hour,
UpstreamRequestTimeout: 90 * time.Minute,
UpstreamResponseTimeout: 0,
RequestMaxBytes: 512 << 10,
ResponseMaxBytes: 1234,
RateLimitPerMinute: 60, RateLimitPerMinute: 60,
RateLimitPerHour: 600, RateLimitPerHour: 600,
RateLimitPerDay: 6000, RateLimitPerDay: 6000,
@@ -403,11 +367,6 @@ func TestValuesAsSet(t *testing.T) {
RulesEnabled: false, RulesEnabled: false,
}) })
if cfg.UpstreamURL.String() != "https://app.internal:8443/" {
t.Errorf("%s is %s", upstreamURL, cfg.UpstreamURL)
}
wantNetblocks(t, cfg.TrustedProxies, "192.0.2.1/32", "10.0.0.0/8", "2001:db8::/32")
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32") wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32") wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24") wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
@@ -581,13 +540,14 @@ func TestCoreRuleSetSettings(t *testing.T) {
environment{ environment{
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10", wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/", wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
wafBodyLimit: "128K", wafBodyLimit: "32K", exclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
config.Config{ config.Config{
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10, WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
WAFDisabledRules: []int{942100, 920350}, WAFDisabledRules: []int{942100, 920350},
WAFExemptPaths: []string{"/api/", "/static/"}, WAFExemptPaths: []string{"/api/", "/static/"},
WAFBodyLimit: 128 << 10, WAFBodyLimit: 32 << 10,
CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
}, },
{ {
@@ -608,6 +568,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold, WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths, WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
WAFBodyLimit: cfg.WAFBodyLimit, WAFBodyLimit: cfg.WAFBodyLimit,
CoreRuleSetExclusionsFile: cfg.CoreRuleSetExclusionsFile,
} }
if !reflect.DeepEqual(got, tc.want) { if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want) t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
@@ -615,12 +576,12 @@ func TestCoreRuleSetSettings(t *testing.T) {
} }
} }
func TestWAFBodyLimitOf1G(t *testing.T) { func TestWAFBodyLimitOf64K(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{wafBodyLimit: "1G"}) cfg := fromEnvironment(t, environment{wafBodyLimit: "64K"})
if cfg.WAFBodyLimit != 1<<30 { if cfg.WAFBodyLimit != 64<<10 {
t.Errorf("1G read as %d", cfg.WAFBodyLimit) t.Errorf("64K read as %d", cfg.WAFBodyLimit)
} }
} }
@@ -630,7 +591,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
const ( const (
notParanoiaLevel = " is not a paranoia level, from 1 to 4" notParanoiaLevel = " is not a paranoia level, from 1 to 4"
setupRule = " is from 900000 to 900999, the ids of the rules that set " + setupRule = " is from 900000 to 900999, the ids of the rules that set " +
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off" "the Core Rule Set up and of sww's own, which cannot be switched off"
) )
for _, tc := range []struct{ name, value, want string }{ for _, tc := range []struct{ name, value, want string }{
@@ -669,10 +630,11 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
wafBodyLimit, "128KB", wafBodyLimit, "128KB",
`"128KB" is not a size such as 512K, 100M or 5G, or off`, `"128KB" is not a size such as 512K, 100M or 5G, or off`,
}, },
{wafBodyLimit, "2G", `"2G" is more than 1G, the most Coraza reads`}, {wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` +
`gets of a body`},
{ {
wafBodyLimit, "1073741825", wafBodyLimit, "65537",
`"1073741825" is more than 1G, the most Coraza reads`, `"65537" is more than 64K, the most discriminator gets of a body`,
}, },
} { } {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
@@ -692,11 +654,11 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
instanceName: "fsn1app1/gitea", instanceName: "fsn1app1/blog",
logRequestHeaders: " Accept , X-Custom", logRequestHeaders: " Accept , X-Custom",
}) })
if cfg.InstanceName != "fsn1app1/gitea" || if cfg.InstanceName != "fsn1app1/blog" ||
!slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) { !slices.Equal(cfg.LogRequestHeaders, []string{"accept", "x-custom"}) {
t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName, t.Errorf("%s is %q and %s gives %v", instanceName, cfg.InstanceName,
logRequestHeaders, cfg.LogRequestHeaders) logRequestHeaders, cfg.LogRequestHeaders)
@@ -839,7 +801,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(t *testing.T) {
want := logRemoteAppName + `: is unset, and ` + instanceName + want := logRemoteAppName + `: is unset, and ` + instanceName +
` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` + ` "fsn1 app1", its default, is not 1 to 48 printable ASCII characters ` +
`without a space, such as gitea` `without a space, such as blog`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -868,7 +830,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
}.lookupEnv) }.lookupEnv)
want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want := logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as gitea` `characters without a space, such as blog`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -898,7 +860,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
const ( const (
webhook = "https://alerts.example:8443/hooks/waf?team=ops" webhook = "https://alerts.example:8443/hooks/waf?team=ops"
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef" slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
ntfy = "https://ntfy.example/smallwebwaf-alerts" ntfy = "https://ntfy.example/sww-alerts"
token = "tk_0123456789abcdefghijklmnopq" token = "tk_0123456789abcdefghijklmnopq"
) )
@@ -946,17 +908,17 @@ func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
wantStartStopped(t, []struct{ name, value string }{ wantStartStopped(t, []struct{ name, value string }{
{alertWebhookURL, "alerts.example/smallwebwaf"}, {alertWebhookURL, "alerts.example/sww"},
{alertWebhookURL, "ftp://alerts.example/"}, {alertWebhookURL, "ftp://alerts.example/"},
{alertWebhookURL, "https:///smallwebwaf"}, {alertWebhookURL, "https:///sww"},
{alertWebhookURL, "https://user:password@alerts.example/"}, {alertWebhookURL, "https://user:password@alerts.example/"},
{alertWebhookURL, "https://alerts.example/#top"}, {alertWebhookURL, "https://alerts.example/#top"},
{alertWebhookURL, "https://alerts.example:0/"}, {alertWebhookURL, "https://alerts.example:0/"},
{alertWebhookURL, "https://alerts.example:65536/"}, {alertWebhookURL, "https://alerts.example:65536/"},
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"}, {alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"}, {alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
{alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"}, {alertNtfyURL, "ntfy://ntfy.example/sww-alerts"},
{alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"}, {alertNtfyURL, "https://ntfy.example/sww-alerts#top"},
{alertWebhookHeaders, "Authorization"}, {alertWebhookHeaders, "Authorization"},
{alertWebhookHeaders, "X Team:ops"}, {alertWebhookHeaders, "X Team:ops"},
{alertWebhookHeaders, ":ops"}, {alertWebhookHeaders, ":ops"},
@@ -1036,7 +998,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv) _, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
want := alertWebhookURL + ": is not an http or https URL without a user or " + want := alertWebhookURL + ": is not an http or https URL without a user or " +
"a fragment, such as https://alerts.example/smallwebwaf" "a fragment, such as https://alerts.example/sww"
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -1050,7 +1012,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef", alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts", alertNtfyURL: "https://ntfy.example/sww-alerts",
alertNtfyToken: token, alertNtfyToken: token,
}) })
@@ -1069,7 +1031,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
} }
} }
for _, secret := range []string{"T0123", "smallwebwaf-alerts", token} { for _, secret := range []string{"T0123", "sww-alerts", token} {
if strings.Contains(logged, secret) { if strings.Contains(logged, secret) {
t.Errorf("%s in the settings logged: %s", secret, logged) t.Errorf("%s in the settings logged: %s", secret, logged)
} }
@@ -1100,7 +1062,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
const name = "fsn1app1\r" const name = "fsn1app1\r"
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts", instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
}.lookupEnv) }.lookupEnv)
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` + want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
@@ -1165,7 +1127,7 @@ func TestLookupSettingsAsSet(t *testing.T) {
cfg = fromEnvironment(t, environment{lookupSource: off}) cfg = fromEnvironment(t, environment{lookupSource: off})
wantLookupSettings(t, cfg, config.Config{ wantLookupSettings(t, cfg, config.Config{
LookupSource: off, LookupTimeout: time.Second, LookupSource: off, LookupTimeout: 800 * time.Millisecond,
}) })
} }
@@ -1242,7 +1204,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
} }
// Set empty, the lists need nothing looked up, and nor does // Set empty, the lists need nothing looked up, and nor does
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly // SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
// threshold per AS number that is off, any other anomaly threshold, or // threshold per AS number that is off, any other anomaly threshold, or
// a blocklist. // a blocklist.
env := environment{ env := environment{
@@ -1480,7 +1442,7 @@ const (
asnURL = "https://lists.example/asn.txt" asnURL = "https://lists.example/asn.txt"
) )
// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them. // The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
const ( const (
actionDeny = "deny" actionDeny = "deny"
actionLimit = "limit" actionLimit = "limit"
@@ -1576,7 +1538,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL, blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
}.lookupEnv) }.lookupEnv)
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too` want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -1879,7 +1841,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
notLAPIURL = " is not an http or https URL without a user or a fragment, " + notLAPIURL = " is not an http or https URL without a user or a fragment, " +
"such as http://172.17.0.1:8080" "such as http://172.17.0.1:8080"
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` + anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too` `SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
) )
for _, tc := range []struct { for _, tc := range []struct {
@@ -1967,45 +1929,15 @@ func TestSizesAndOff(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := fromEnvironment(t, environment{
requestMaxBytes: "3G", bytesLimitPerMinute: "3G",
responseMaxBytes: off, bytesLimitPerHour: off,
clientRequestTimeout: off, alertCooldown: off,
clientIdleTimeout: off,
}) })
if cfg.RequestMaxBytes != 3<<30 || cfg.ResponseMaxBytes != 0 || if cfg.BytesLimitPerMinute != 3<<30 || cfg.BytesLimitPerHour != 0 ||
cfg.ClientRequestTimeout != 0 || cfg.ClientIdleTimeout != 0 { cfg.AlertCooldown != 0 {
t.Errorf("3G, off, off and off read as %d, %d, %s and %s", t.Errorf("3G, off and off read as %d, %d and %s",
cfg.RequestMaxBytes, cfg.ResponseMaxBytes, cfg.ClientRequestTimeout, cfg.BytesLimitPerMinute, cfg.BytesLimitPerHour, cfg.AlertCooldown)
cfg.ClientIdleTimeout)
}
}
func TestRequestHeaderMaxBytesJustOver4K(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{clientHeaderMaxBytes: "4097"})
if cfg.ClientRequestHeaderMaxBytes != 4097 {
t.Errorf("4097 read as %d", cfg.ClientRequestHeaderMaxBytes)
}
}
func TestRequestHeaderMaxBytesRefusalNeverOffersOff(t *testing.T) {
t.Parallel()
for _, value := range []string{"32KB", "0", "4K", off} {
t.Run(value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(
environment{clientHeaderMaxBytes: value}.lookupEnv)
want := clientHeaderMaxBytes + `: "` + value +
`" is not a size of more than 4K, such as 32K`
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
} }
} }
@@ -2034,34 +1966,12 @@ func TestBanResponseCloseIsZero(t *testing.T) {
} }
} }
func TestTrustedProxiesSetButEmptyTrustNothing(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{trustedProxies: ""})
if len(cfg.TrustedProxies) != 0 {
t.Errorf("trusted proxies %v, want none", cfg.TrustedProxies)
}
}
func TestInvalidValueStopsTheStart(t *testing.T) { func TestInvalidValueStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
wantStartStopped(t, []struct{ name, value string }{ wantStartStopped(t, []struct{ name, value string }{
{listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"}, {listenAddr, "8080"}, {listenAddr, ":http"}, {listenAddr, ":65536"},
{upstreamURL, "127.0.0.1:8081"},
{upstreamURL, "ftp://127.0.0.1:8081"},
{upstreamURL, "http://"},
{upstreamURL, "http://:8081"},
{upstreamURL, "http://127.0.0.1:0"},
{upstreamURL, "http://127.0.0.1:99999"},
{upstreamURL, "http://127.0.0.1:8081/app"},
{upstreamURL, "http://127.0.0.1:8081/?a=1"},
{upstreamURL, "http://user:secret@127.0.0.1:8081"},
{mode, "Observe"}, {mode, "block"}, {mode, ""}, {mode, "Observe"}, {mode, "block"}, {mode, ""},
{trustedProxies, "10.0.0.0/33"},
{trustedProxies, "traefik"},
{trustedProxies, "10.0.0.0/8,,192.168.0.0/16"},
{trustedProxies, "fe80::1%eth0"},
{ipv6GroupPrefix, "31"}, {ipv6GroupPrefix, "129"}, {ipv6GroupPrefix, "/64"}, {ipv6GroupPrefix, "31"}, {ipv6GroupPrefix, "129"}, {ipv6GroupPrefix, "/64"},
{ipv6GroupPrefix, off}, {ipv6GroupPrefix, ""}, {ipv6GroupPrefix, off}, {ipv6GroupPrefix, ""},
{maxTrackedClients, "0"}, {maxTrackedClients, "-1"}, {maxTrackedClients, off}, {maxTrackedClients, "0"}, {maxTrackedClients, "-1"}, {maxTrackedClients, off},
@@ -2069,19 +1979,18 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{allowNets, "192.0.2.0/24,monitoring"}, {allowNets, "192.0.2.0/24,monitoring"},
{rateLimitExemptNets, "2001:db8::/129"}, {rateLimitExemptNets, "2001:db8::/129"},
{denyNets, "198.51.100.0/24,"}, {denyNets, "198.51.100.0/24,"},
{clientRequestTimeout, "60"}, {clientRequestTimeout, ""}, {alertCooldown, "60"}, {alertCooldown, ""},
{clientIdleTimeout, "0s"}, {alertCooldown, "0s"},
{clientIdleTimeout, "2 minutes"}, {alertCooldown, "2 minutes"},
{clientResponseTimeout, "1y"}, {alertCooldown, "1y"},
{upstreamRequestTimeout, "-1s"}, {alertCooldown, "-1s"},
{upstreamResponseTimeout, "0s"}, {alertCooldown, "1.5d"},
{upstreamResponseTimeout, "1.5d"}, {bytesLimitPerMinute, "100MB"},
{requestMaxBytes, "100MB"}, {bytesLimitPerMinute, "100m"},
{requestMaxBytes, "100m"}, {bytesLimitPerMinute, "1.5M"},
{requestMaxBytes, "1.5M"}, {bytesLimitPerHour, "0"},
{responseMaxBytes, "0"}, {bytesLimitPerHour, "-5"},
{responseMaxBytes, "-5"}, {bytesLimitPerHour, "99999999999G"},
{responseMaxBytes, "99999999999G"},
{rateLimitPerMinute, ""}, {rateLimitPerMinute, ""},
{rateLimitPerMinute, "1K"}, {rateLimitPerMinute, "1K"},
{rateLimitPerHour, "0"}, {rateLimitPerHour, "0"},
@@ -2134,7 +2043,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off}, {maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"}, {maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"}, {banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"}, {stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
{stateWriteDelay, off}, {stateWriteDelay, "0s"}, {stateWriteDelay, off}, {stateWriteDelay, "0s"},
{stateCounterInterval, off}, {stateCounterInterval, "15"}, {stateCounterInterval, off}, {stateCounterInterval, "15"},
}) })
@@ -2246,10 +2155,10 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
t.Parallel() t.Parallel()
_, err := config.FromEnvironment(environment{ _, err := config.FromEnvironment(environment{
requestMaxBytes + "_FILE": writeFile(t, "lots\n"), bytesLimitPerMinute + "_FILE": writeFile(t, "lots\n"),
}.lookupEnv) }.lookupEnv)
want := requestMaxBytes + `: "lots" is not a size such as 512K, 100M or 5G, or off` want := bytesLimitPerMinute + `: "lots" is not a size such as 512K, 100M or 5G, or off`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -2261,7 +2170,7 @@ func TestSettingFromFileIsCheckedAsTheSettingItself(t *testing.T) {
}.lookupEnv) }.lookupEnv)
want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` + want = logRemoteAppName + `: "my app" is not 1 to 48 printable ASCII ` +
`characters without a space, such as gitea` `characters without a space, such as blog`
if err == nil || err.Error() != want { if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
@@ -2354,38 +2263,28 @@ func writeFile(t *testing.T, contents string) string {
func TestLogsEachSettingWithItsValue(t *testing.T) { func TestLogsEachSettingWithItsValue(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{clientRequestTimeout: "45s"}) cfg := fromEnvironment(t, environment{rateLimitPerMinute: "45"})
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
want := map[string]string{ want := map[string]string{
listenAddr: ":8080", listenAddr: ":8080",
upstreamURL: "http://127.0.0.1:8081",
mode: "enforce", mode: "enforce",
trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
ipv6GroupPrefix: "64", ipv6GroupPrefix: "64",
maxTrackedClients: "20000", maxTrackedClients: "20000",
clientRequestTimeout: "45s",
clientHeaderMaxBytes: "32K",
clientIdleTimeout: "120s",
clientResponseTimeout: "30m",
upstreamRequestTimeout: "60s",
upstreamResponseTimeout: "30m",
requestMaxBytes: "100M",
responseMaxBytes: "1G",
allowNets: "", allowNets: "",
rateLimitExemptNets: "", rateLimitExemptNets: "",
denyNets: "", denyNets: "",
rateLimitPerMinute: "6000", rateLimitPerMinute: "45",
rateLimitPerHour: "100000", rateLimitPerHour: "1500",
rateLimitPerDay: "1000000", rateLimitPerDay: "6000",
rateLimitExemptPaths: "", rateLimitExemptPaths: "",
bytesLimitPerMinute: "4G", bytesLimitPerMinute: "off",
bytesLimitPerHour: "20G", bytesLimitPerHour: "50G",
bytesLimitPerDay: "200G", bytesLimitPerDay: "100G",
bytesCount: "both", bytesCount: "both",
lookupSource: defaultLookupSource, lookupSource: defaultLookupSource,
lookupDBPath: "", lookupDBPath: "",
lookupTimeout: "1s", lookupTimeout: "800ms",
addLookupHeaders: "false", addLookupHeaders: "false",
deniedCountries: "", deniedCountries: "",
allowedCountries: "", allowedCountries: "",
@@ -2415,7 +2314,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
attackBanDuration: "7d", attackBanDuration: "7d",
maxBans: "5000", maxBans: "5000",
banScopeV4Prefix: "32", banScopeV4Prefix: "32",
stateDir: "/var/lib/smallwebwaf", stateDir: "/var/lib/sww",
stateWriteDelay: "10s", stateWriteDelay: "10s",
stateCounterInterval: "15m", stateCounterInterval: "15m",
adminToken: "", adminToken: "",
@@ -2424,7 +2323,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
instanceName: hostname, instanceName: hostname,
logRequestHeaders: defaultLogRequestHeaders, logRequestHeaders: defaultLogRequestHeaders,
logLevel: "info", logLevel: "info",
rulesDir: "/etc/smallwebwaf/rules.d", rulesDir: "/etc/sww/rules.d",
rulesEnabled: "true", rulesEnabled: "true",
wafMode: config.WAFModeBlock, wafMode: config.WAFModeBlock,
wafParanoiaLevel: "1", wafParanoiaLevel: "1",
@@ -2432,6 +2331,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafDisabledRules: defaultWAFDisabledRules, wafDisabledRules: defaultWAFDisabledRules,
wafExemptPaths: "", wafExemptPaths: "",
wafBodyLimit: off, wafBodyLimit: off,
exclusionsFile: "",
trapPaths: "", trapPaths: "",
errorBurstThreshold: "30", errorBurstThreshold: "30",
logRemoteURL: "", logRemoteURL: "",
@@ -2481,14 +2381,6 @@ func wantSettings(t *testing.T, got *config.Config, want config.Config) {
if got.ListenAddr != want.ListenAddr || if got.ListenAddr != want.ListenAddr ||
got.Observe != want.Observe || got.Observe != want.Observe ||
got.ClientRequestTimeout != want.ClientRequestTimeout ||
got.ClientRequestHeaderMaxBytes != want.ClientRequestHeaderMaxBytes ||
got.ClientIdleTimeout != want.ClientIdleTimeout ||
got.ClientResponseTimeout != want.ClientResponseTimeout ||
got.UpstreamRequestTimeout != want.UpstreamRequestTimeout ||
got.UpstreamResponseTimeout != want.UpstreamResponseTimeout ||
got.RequestMaxBytes != want.RequestMaxBytes ||
got.ResponseMaxBytes != want.ResponseMaxBytes ||
got.RateLimitPerMinute != want.RateLimitPerMinute || got.RateLimitPerMinute != want.RateLimitPerMinute ||
got.RateLimitPerHour != want.RateLimitPerHour || got.RateLimitPerHour != want.RateLimitPerHour ||
got.RateLimitPerDay != want.RateLimitPerDay { got.RateLimitPerDay != want.RateLimitPerDay {
+192
View File
@@ -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
}
+231
View File
@@ -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)
}
}
}
@@ -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"
@@ -50,16 +49,13 @@ func (rq *request) answerHealth() {
_, _ = io.WriteString(rq.out, "ok\n") _, _ = io.WriteString(rq.out, "ok\n")
} }
// answerAdmin answers a request for smallwebwaf itself, under // answerAdmin answers a request for one of discriminator's own endpoints. Each
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a // needs a token, sent as Authorization: Bearer <token>: the metrics
// token, sent as Authorization: Bearer <token>: the metrics // SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without // refused with 401. An endpoint whose token is unset answers 404, as any other
// it is refused with 401, which counts toward the error burst. An // path does.
// endpoint whose token is unset answers 404, as any other request under
// /_smallwebwaf/ 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()
@@ -67,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()
} }
@@ -129,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
@@ -177,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)
} }
@@ -280,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,
@@ -1,4 +1,4 @@
package proxy package discriminator
import ( import (
"net/netip" "net/netip"
@@ -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()
+570
View File
@@ -0,0 +1,570 @@
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 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{deniedCountries: "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{
deniedCountries: "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,4 +1,4 @@
package proxy package discriminator
import ( import (
"net/http" "net/http"
@@ -12,7 +12,7 @@ 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}
@@ -58,13 +58,12 @@ func (rq *request) limitBroken(now time.Time) bool {
} }
// countBytes counts the request's bytes, as countedBytes gives them, for // countBytes counts the request's bytes, as countedBytes gives them, for
// the byte limits, once its response has ended, and notes the client's // the byte limits, once its report has come, and notes the client's byte totals
// byte totals for the log line; its requests stay there as the rate limits // for the log line; its requests stay there as the rate limits counted them.
// counted them. Only a request passed to the app has them counted, and // Only an allowed request has them counted, and only one the rate limits
// only one the rate limits counted; in observe mode, not one that enforce // counted; in observe mode, not one that enforce mode would have refused. Bytes
// mode would have refused. Bytes that take the client over a byte limit, // that take the client over a byte limit, as its limit percentage for the byte
// as its limit percentage for the byte limits lowers it, break it; the // limits lowers it, break it; the response was passed on whole.
// response was passed on whole.
func (rq *request) countBytes() { func (rq *request) countBytes() {
if !rq.counted || rq.line.WouldAction != "" { if !rq.counted || rq.line.WouldAction != "" {
return return
@@ -84,11 +83,10 @@ func (rq *request) countBytes() {
} }
// countRefusal counts the request for the error burst once it has been // countRefusal counts the request for the error burst once it has been
// answered, if smallwebwaf refused it after a rule file match, a trap path // answered, if discriminator refused it after a rule file match, a trap path or a
// or a Core Rule Set match, or for a missing or wrong token, and in // Core Rule Set match, and in observe mode if enforce mode would have: more than
// observe mode if enforce mode would have: more than // SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute // break a limit. A client in SWW_ALLOW_NETS, which the checks skip, is
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
// not counted, and nothing is while the threshold is off. // not counted, and nothing is while the threshold is off.
func (rq *request) countRefusal() { func (rq *request) countRefusal() {
cfg := rq.h.config cfg := rq.h.config
@@ -96,11 +94,7 @@ func (rq *request) countRefusal() {
return return
} }
// In observe mode, a request that enforce mode would have refused if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
// 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 return
} }
@@ -127,16 +121,9 @@ func (rq *request) countRefusal() {
// 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 +138,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 +277,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,
@@ -356,7 +343,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,7 +26,7 @@ const (
userAgent = "ban-test/1.0" userAgent = "ban-test/1.0"
// permanent is the log line's ban_expires for a permanent ban. // permanent is the log line's ban_expires for a permanent ban.
permanent = "permanent" permanent = "permanent"
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection. // banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
banResponseClose = "close" banResponseClose = "close"
) )
@@ -88,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)
@@ -147,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 {
@@ -163,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},
}, },
@@ -172,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"},
}, },
@@ -230,10 +231,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
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},
@@ -351,7 +352,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
@@ -374,14 +375,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) {})
@@ -391,16 +392,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
} }
@@ -419,22 +422,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
@@ -450,10 +450,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 {
@@ -478,7 +482,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"
@@ -18,7 +18,7 @@ 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: one of its lists is not empty,
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_ASN_LIMIT_PERCENT_URL // SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL
// is set. The client's lookup is then needed before its request goes on. // 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.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
@@ -31,52 +31,52 @@ func biasedThresholdsSet(cfg *config.Config) bool {
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB // up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
// that list it. Each is the lowest of those the settings give it, the // that list it. Each is the lowest of those the settings give it, the
// first of them in the order below when several are lowest: the // 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_ASN_LIMIT_PERCENT gives its AS number, the one the file
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one // SWW_ASN_LIMIT_PERCENT_URL names gives it, the one
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a // SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
// country, SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, // country, unless its request skipped the wait for GeoJS,
// the percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a // SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
// client a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, // percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client
// the percentage of SWWAF_REPUTATION_ACTION while it is limit. For the // a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
// byte limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT // percentage of SWW_REPUTATION_ACTION while it is limit. For the byte
// take the place of the first three for an AS number or a country they // limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the
// list. // place of the first three for an AS number or a country they list.
func (rq *request) limitPercentages() (percentage, percentage) { func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config cfg := rq.h.config
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"), asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"),
fetched) fetched)
countryRequests := given(cfg.CountryLimitPercent, country, countryRequests := given(cfg.CountryLimitPercent, country,
"SWWAF_COUNTRY_LIMIT_PERCENT") "SWW_COUNTRY_LIMIT_PERCENT")
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, unknown, blocklisted, reputationListed),
@@ -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,18 @@ 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" asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT" countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT" asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT" countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT" unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
) )
const ( const (
@@ -231,7 +230,7 @@ func TestLowestPercentageApplies(t *testing.T) {
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{asnLimitPercent: "AS64500:25"},
noCountry, "25 from " + asnLimitPercent, noCountry, "25 from " + asnLimitPercent,
@@ -281,12 +280,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)
@@ -320,13 +319,8 @@ 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{lookupTimeout: timeout.String(), tc.setting: tc.value})
server, out, _ := newProxy(t, "http://app.invalid", unansweredGeoJSURL,
time.Now, map[string]string{
lookupTimeout: timeout.String(), requestMaxBytes: "1",
tc.setting: tc.value,
})
began := time.Now() began := time.Now()
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab")) serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
@@ -340,8 +334,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
@@ -426,7 +419,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 +434,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 +460,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 +487,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_ASN_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 {
@@ -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,7 +92,7 @@ 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)
}) })
} }
@@ -126,7 +124,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 +166,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 +212,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 +430,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 +486,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)
+62
View File
@@ -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)
})
}
@@ -1,9 +1,6 @@
package proxy package discriminator
import ( import (
"errors"
"net/http"
"os"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
@@ -13,14 +10,13 @@ import (
) )
// checkCoreRuleSet inspects the request with the Core Rule Set, unless // checkCoreRuleSet inspects the request with the Core Rule Set, unless
// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as // 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 // 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 // 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, // SWW_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 // and in block mode refuses the request, which is an offence its client's
// history counts, and so returns ActionWAFBlocked. It returns "" for a // 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 // request it does not refuse.
// limit while the Core Rule Set reads it, which it notes nothing of.
func (rq *request) checkCoreRuleSet() string { func (rq *request) checkCoreRuleSet() string {
cfg := rq.h.config cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) { if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
@@ -29,10 +25,10 @@ func (rq *request) checkCoreRuleSet() string {
start := time.Now() start := time.Now()
result := rq.inspect() // A question's body has been read into memory, so that reading it here
if rq.refused.Load() != nil { // cannot fail.
return "" // the refusal for that limit, which check returns 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.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
rq.line.WAFRuleIDs = result.RuleIDs rq.line.WAFRuleIDs = result.RuleIDs
@@ -58,41 +54,8 @@ func (rq *request) checkCoreRuleSet() string {
return requestlog.ActionWAFBlocked 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 // 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 // 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 // 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, // query, and, for a request that is not refused for it, the mode: detect,
// or observe in observe mode. // or observe in observe mode.
@@ -119,7 +82,7 @@ func (rq *request) alertWAFBlock(result waf.Result) {
ASN: rq.line.ASN, ASN: rq.line.ASN,
ASName: rq.line.ASName, ASName: rq.line.ASName,
Country: rq.line.Country, Country: rq.line.Country,
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,
}) })
} }
@@ -1,4 +1,4 @@
package proxy_test package discriminator_test
import ( import (
"io" "io"
@@ -8,20 +8,19 @@ 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/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"
block = "block" block = "block"
detect = "detect" detect = "detect"
) )
@@ -29,9 +28,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
@@ -96,7 +102,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...)
} }
@@ -119,7 +125,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 +134,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 +153,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)
@@ -299,7 +305,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 +315,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 +334,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 +365,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 +392,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 +465,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,
}) })
@@ -1,4 +1,4 @@
package proxy package discriminator
import ( import (
"slices" "slices"
@@ -7,7 +7,7 @@ import (
// countryDenied reports whether the country lists refuse the request, by // countryDenied reports whether the country lists refuse the request, by
// the client's country as it was looked up. A client without a country, // the client's country as it was looked up. A client without a country,
// or whose country cannot be found, is refused only by // or whose country cannot be found, is refused only by
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES. // SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
func (rq *request) countryDenied() bool { func (rq *request) countryDenied() bool {
denied := rq.h.config.DeniedCountries denied := rq.h.config.DeniedCountries
allowed := rq.h.config.ExclusivelyAllowedCountries allowed := rq.h.config.ExclusivelyAllowedCountries
@@ -1,4 +1,4 @@
package proxy_test package discriminator_test
import ( import (
"encoding/json" "encoding/json"
@@ -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,7 @@ func TestCountryLists(t *testing.T) {
} }
} }
func TestCountryRefusalComesBeforeTheBody(t *testing.T) { func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
t.Parallel() t.Parallel()
var calls atomic.Int32 var calls atomic.Int32
@@ -109,13 +109,9 @@ func TestCountryRefusalComesBeforeTheBody(t *testing.T) {
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())
} }
@@ -211,7 +207,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 +245,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 +279,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 +350,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 {
@@ -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,14 +84,14 @@ 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()))
} }
@@ -168,7 +168,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()
@@ -1,4 +1,4 @@
package proxy package discriminator
import ( import (
"bytes" "bytes"
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/netip" "net/netip"
"strconv" "strconv"
@@ -13,23 +14,21 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
lru "github.com/hashicorp/golang-lru/v2"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The headers of rproxy's question and of servewaf's answer, as // The headers of rproxy's question and of discriminator's answer, as
// "servewaf" in SPEC.md gives them. // "discriminator" in SPEC.md gives them.
const ( const (
// ClientHeader marks a request as rproxy's question, and gives the // ClientHeader marks a request as rproxy's question, and gives the
// visitor's address. // visitor's address.
ClientHeader = "X-Smallwebwaf-Client" ClientHeader = "X-Sww-Client"
// PeerHeader gives the address of rproxy's own TCP peer. // PeerHeader gives the address of rproxy's own TCP peer.
PeerHeader = "X-Smallwebwaf-Peer" PeerHeader = "X-Sww-Peer"
// ContentLengthHeader gives the body length the visitor announced. // ContentLengthHeader gives the body length the visitor announced.
ContentLengthHeader = "X-Smallwebwaf-Content-Length" ContentLengthHeader = "X-Sww-Content-Length"
// DispositionHeader gives servewaf's answer. // DispositionHeader gives discriminator's answer.
DispositionHeader = "X-Smallwebwaf-Disposition" DispositionHeader = "X-Sww-Disposition"
) )
// The values of DispositionHeader. // The values of DispositionHeader.
@@ -40,19 +39,26 @@ const (
// headers and body. // headers and body.
DispositionDeny = "deny" DispositionDeny = "deny"
// DispositionClose tells rproxy to close the visitor's connection // DispositionClose tells rproxy to close the visitor's connection
// without an answer, which SWWAF_BAN_RESPONSE asks for when it is close. // without an answer, which SWW_BAN_RESPONSE asks for when it is close.
// servewaf closing the question's connection instead would read as // discriminator closing the question's connection instead would read as
// servewaf being down, and rproxy would then let the request through. // discriminator being down, and rproxy would then let the request through.
DispositionClose = "close" DispositionClose = "close"
) )
// ReportPath is where rproxy sends the report of each request servewaf // ReportPath is where rproxy sends the report of each request discriminator
// answered. // answered.
const ReportPath = "/_smallwebwaf/report" const ReportPath = "/_sww/report"
// QuestionBodyMaxBytes is the most of a visitor's body a question carries. // QuestionBodyMaxBytes is the most of a visitor's body a question carries.
const QuestionBodyMaxBytes = 64 << 10 const QuestionBodyMaxBytes = 64 << 10
// The header limits: the most cookies and header fields a request may have
// when the checks reach the Core Rule Set. Real clients never reach them.
const (
maxCookies = 50
maxHeaderFields = 100
)
const ( const (
// maxAwaitingReport is the most answered requests held until their // maxAwaitingReport is the most answered requests held until their
// reports come. Past it, the request answered longest ago is dropped. // reports come. Past it, the request answered longest ago is dropped.
@@ -70,11 +76,11 @@ const (
// maxReportedMilliseconds is the longest duration a report may give, // maxReportedMilliseconds is the longest duration a report may give,
// about 31 years, so that it fits a time.Duration. // about 31 years, so that it fits a time.Duration.
maxReportedMilliseconds = 1e12 maxReportedMilliseconds = 1e12
// serveWAFTimeout bounds how long a request to servewaf takes to arrive, // discriminatorTimeout bounds how long a request to discriminator takes to
// and its answer to leave, and serveWAFIdleTimeout how long a kept-open // arrive, and its answer to leave, and discriminatorIdleTimeout how long a
// connection waits for its next request. // kept-open connection waits for its next request.
serveWAFTimeout = 60 * time.Second discriminatorTimeout = 60 * time.Second
serveWAFIdleTimeout = 120 * time.Second discriminatorIdleTimeout = 120 * time.Second
) )
var ( var (
@@ -97,46 +103,40 @@ type Report struct {
DurationUpstreamTotal float64 `json:"duration_upstream_total"` DurationUpstreamTotal float64 `json:"duration_upstream_total"`
} }
// NewServeWAF returns the server `smallwebwaf servewaf` runs. It answers // New returns the server `sww discriminator` runs. It answers
// rproxy's questions with the checks New runs, takes rproxy's reports and // rproxy's questions, takes rproxy's reports and answers its own endpoints. It
// answers its own endpoints. It passes nothing to an app. rproxy applies // passes nothing to an app. rproxy applies the size and time limits to the
// the size and time limits to the visitor, so their settings do not apply // visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
// here. Go's default limit on a request's line and headers, 1 MiB, stays: // is far above rproxy's, and a question refused for its size would have rproxy
// it is far above rproxy's, and a question refused for its size would have // let the request through.
// rproxy let the request through. func New(params Params) *Server {
func NewServeWAF(params Params) *Server {
cfg := *params.Config
cfg.RequestMaxBytes = 0
cfg.ClientRequestTimeout = serveWAFTimeout
// The server's WriteTimeout does this. It sets a new deadline for each
// request, where a deadline a handler sets stays on a kept-open
// connection, and would run out under a later request's answer.
cfg.ClientResponseTimeout = 0
// 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.
cfg.WAFBodyLimit = min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1)
params.Config = &cfg
h := newHandler(params) h := newHandler(params)
awaitingReport, err := lru.New[string, *decision](maxAwaitingReport) return &Server{
if err != nil { Server: &http.Server{
panic(err) // New fails only for a size below one 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,
}
} }
h.awaitingReport = awaitingReport // ServeHTTP handles one request to discriminator: a request carrying
// ClientHeader is rproxy's question, and any other is for discriminator
return h.server(&http.Server{ // itself.
Handler: http.HandlerFunc(h.serveWAF), func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ReadTimeout: serveWAFTimeout,
WriteTimeout: serveWAFTimeout,
IdleTimeout: serveWAFIdleTimeout,
})
}
// serveWAF handles one request to servewaf: a request carrying
// ClientHeader is rproxy's question, and any other is for servewaf itself.
func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
if len(r.Header.Values(ClientHeader)) > 0 { if len(r.Header.Values(ClientHeader)) > 0 {
h.answerQuestion(w, r) h.answerQuestion(w, r)
@@ -146,6 +146,52 @@ func (h *handler) serveWAF(w http.ResponseWriter, r *http.Request) {
h.answerOwnEndpoint(w, r) 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: it is over the header limits, a blocklist lists its client while
// SWW_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
// over a whole rate limit. 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
}
lists := rq.h.lists
_, decided := lists.CrowdSecDecision(rq.client, now)
return decided || rq.overHeaderLimits() ||
(rq.h.config.BlocklistAction == deny && len(lists.ListedBy(rq.client)) > 0) ||
(rq.countedByRateLimits() &&
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
}
// overHeaderLimits reports whether discriminator refuses the request for its
// headers: it has more than maxHeaderFields fields, each value of a header sent
// twice being one, or more than maxCookies cookies, each part of a Cookie header
// between semicolons being one unless it is empty or only spaces and tabs. The
// Core Rule Set runs each of its rules on each cookie, so that thousands of small
// cookies keep it busy for over a second. It trims only spaces and control
// characters, of which a header value can hold only tabs, so a part of Unicode
// spaces is a cookie to it.
func (rq *request) overHeaderLimits() bool {
fields, cookies := 0, 0
for _, values := range rq.in.Header {
fields += len(values)
}
for _, value := range rq.in.Header.Values("Cookie") {
for part := range strings.SplitSeq(value, ";") {
if strings.Trim(part, " \t") != "" {
cookies++
}
}
}
return fields > maxHeaderFields || cookies > maxCookies
}
// answerQuestion runs the checks on the visitor's request a question // answerQuestion runs the checks on the visitor's request a question
// carries, and answers rproxy what to do with it. A denied request's counting // 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. // is finished here, and an allowed one's by its report.
@@ -167,7 +213,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
defer rq.finish() defer rq.finish()
rq.site = r.Host rq.site = r.Host
rq.line.ServeWAFLine = requestlog.ServeWAFLineDecision rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
refused := rq.check(r.Context()) refused := rq.check(r.Context())
rq.checked = time.Now() rq.checked = time.Now()
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix) underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
@@ -181,7 +227,8 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
return return
} }
// The last deferred runs first, as in ServeHTTP. // 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.addToHistory()
defer rq.countAnomalies() defer rq.countAnomalies()
defer rq.countRefusal() defer rq.countRefusal()
@@ -192,7 +239,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
return return
} }
// The app never sees this prefix. servewaf's own endpoints are at its // The app never sees this prefix. discriminator's own endpoints are at its
// own address, so this answers as a path with no endpoint does. // own address, so this answers as a path with no endpoint does.
rq.line.Action = requestlog.ActionAdmin rq.line.Action = requestlog.ActionAdmin
rq.out.Header().Set(DispositionHeader, DispositionDeny) rq.out.Header().Set(DispositionHeader, DispositionDeny)
@@ -200,7 +247,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
} }
// readQuestion returns the visitor's address and the body a question carries, // readQuestion returns the visitor's address and the body a question carries,
// and whether servewaf takes the question. It answers a question it does not // and whether discriminator takes the question. It answers a question it does not
// take with 400 and no disposition, which rproxy treats as it treats no // take with 400 and no disposition, which rproxy treats as it treats no
// answer. The id of a question it takes is held from here on, without a // answer. The id of a question it takes is held from here on, without a
// decision yet, so that another question with that id is refused while this // decision yet, so that another question with that id is refused while this
@@ -243,7 +290,7 @@ func (h *handler) readQuestion(
held, _ := h.awaitingReport.ContainsOrAdd(id, nil) held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
if held { if held {
h.refuseQuestion(w, requestIDHeader+" is the id of a request servewaf holds") h.refuseQuestion(w, requestIDHeader+" is the id of a request discriminator holds")
return netip.Addr{}, nil, false return netip.Addr{}, nil, false
} }
@@ -251,17 +298,17 @@ func (h *handler) readQuestion(
return client, body, true return client, body, true
} }
// refuseQuestion answers a question servewaf does not take, for reason. // refuseQuestion answers a question discriminator does not take, for reason.
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) { func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
h.processLog.Warn("a question is refused", "reason", reason) h.processLog.Warn("a question is refused", "reason", reason)
http.Error(w, reason, http.StatusBadRequest) http.Error(w, reason, http.StatusBadRequest)
} }
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the // asTheVisitorSentIt returns a copy of r, a question, as the checks and the
// log line are to see it: without the headers rproxy adds for servewaf, with // log line are to see it: without the headers rproxy adds for discriminator,
// body, which has been read of r, and with the length the visitor announced, // with body, which has been read of r, and with the length the visitor
// as -1 when it announced none. The question's own length covers only the part // announced, as -1 when it announced none. The question's own length covers
// of the body it carries. // only the part of the body it carries.
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request { func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
sent := r.Clone(r.Context()) sent := r.Clone(r.Context())
announced := sent.Header.Get(ContentLengthHeader) announced := sent.Header.Get(ContentLengthHeader)
@@ -288,7 +335,7 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
} }
// allow answers a question with DispositionAllow and, while // allow answers a question with DispositionAllow and, while
// SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country. // SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
func (rq *request) allow() { func (rq *request) allow() {
rq.allowed = true rq.allowed = true
@@ -311,10 +358,11 @@ func (rq *request) holdForReport() {
// deny answers a question with what the visitor is to get for r. // deny answers a question with what the visitor is to get for r.
func (rq *request) deny(r refusal) { func (rq *request) deny(r refusal) {
rq.refused = &r
if r.status == 0 { if r.status == 0 {
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's // SWW_BAN_RESPONSE is close. Written past rq.out, since the log line's
// status stays 0: the visitor is sent nothing. // status stays 0: the visitor is sent nothing.
rq.refused.CompareAndSwap(nil, &r)
rq.out.Header().Set(DispositionHeader, DispositionClose) rq.out.Header().Set(DispositionHeader, DispositionClose)
rq.out.ResponseWriter.WriteHeader(http.StatusOK) rq.out.ResponseWriter.WriteHeader(http.StatusOK)
@@ -322,10 +370,10 @@ func (rq *request) deny(r refusal) {
} }
rq.out.Header().Set(DispositionHeader, DispositionDeny) rq.out.Header().Set(DispositionHeader, DispositionDeny)
rq.answer(r) http.Error(rq.out, http.StatusText(r.status), r.status)
} }
// answerOwnEndpoint answers a request to servewaf's own address. No check // answerOwnEndpoint answers a request to discriminator's own address. No check
// runs on it: the address is on the private network, where the peers are // runs on it: the address is on the private network, where the peers are
// rproxy and the admin's tools, not visitors. // rproxy and the admin's tools, not visitors.
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) { func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
@@ -362,7 +410,8 @@ func (h *handler) takeReport(w http.ResponseWriter, r *http.Request) {
} }
held, found := h.awaitingReport.Peek(report.RequestID) held, found := h.awaitingReport.Peek(report.RequestID)
// Remove reports true to one report only, of several that name the id. // 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) { if found && held != nil && h.awaitingReport.Remove(report.RequestID) {
h.finishWith(held, report) h.finishWith(held, report)
} }
@@ -427,20 +476,19 @@ func (report *Report) checkRanges() error {
// finishWith finishes a request with what its report says, and writes the // finishWith finishes a request with what its report says, and writes the
// request's last log line, which holds its decision's fields and the report's. // request's last log line, which holds its decision's fields and the report's.
// For an allowed request it first takes the steps ServeHTTP takes once a // For an allowed request it first counts the request's bytes, and adds it to its
// request passed to the app has ended, with the status and bytes of the report // client's history, the anomaly counters and the metrics, with the status and
// in place of those it would have seen. A denied request's counting was // bytes the report gives. A denied request's counting was finished at its
// finished at its decision. // decision.
func (h *handler) finishWith(held *decision, report Report) { func (h *handler) finishWith(held *decision, report Report) {
rq := &request{ rq := &request{
decision: *held, decision: *held,
h: h, h: h,
out: &responseWriter{status: report.Status, bytes: report.ResponseBytes}, out: &responseWriter{status: report.Status, bytes: report.ResponseBytes},
body: &requestBody{}, requestBytes: report.RequestBytes,
} }
rq.body.bytes.Store(report.RequestBytes)
rq.line.ServeWAFLine = requestlog.ServeWAFLineReport rq.line.DiscriminatorLine = requestlog.DiscriminatorLineReport
rq.line.Status = report.Status rq.line.Status = report.Status
rq.line.RequestBytes = report.RequestBytes rq.line.RequestBytes = report.RequestBytes
rq.line.ResponseBytes = report.ResponseBytes rq.line.ResponseBytes = report.ResponseBytes
@@ -448,8 +496,6 @@ func (h *handler) finishWith(held *decision, report Report) {
if rq.allowed { if rq.allowed {
rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal rq.line.DurationUpstreamTotal = &report.DurationUpstreamTotal
// addToHistory goes by this for whether the request reached the app.
rq.upstreamStart = time.Now()
rq.countBytes() rq.countBytes()
rq.countAnomalies() rq.countAnomalies()
File diff suppressed because it is too large. Load diff
@@ -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()
@@ -130,13 +109,11 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) { func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
t.Parallel() t.Parallel()
const scraper = "192.0.2.200"
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 +150,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) {
@@ -283,27 +258,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 +307,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 +317,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 +331,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)
+40
View File
@@ -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)
}
}
}
@@ -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"
) )
@@ -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")
@@ -1,18 +1,18 @@
package proxy package discriminator
import ( import (
"context" "context"
"net/http" "net/http"
"net/netip" "net/netip"
"time"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
) )
// The headers in which the app is passed the client's AS number and // The headers in which an answer of allow gives rproxy the client's AS number
// country while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes every header // and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes
// name in this form, as it sends it and as it receives it, so X-Client-ASN // every header name in this form, as it sends it and as it receives it, so
// arrives as X-Client-Asn, and Del removes a client's own whatever their // X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
// case; header names are not case-sensitive.
const ( const (
asnHeader = "X-Client-Asn" asnHeader = "X-Client-Asn"
countryHeader = "X-Client-Country" countryHeader = "X-Client-Country"
@@ -20,20 +20,27 @@ const (
// lookUp looks up the client's AS number and country, in the lookup // lookUp looks up the client's AS number and country, in the lookup
// database or through GeoJS, and notes them for the log line, unless // database or through GeoJS, and notes them for the log line, unless
// SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or // SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
// link-local address, which no lookup can place. The lookup database // link-local address, which no lookup can place. The lookup database
// answers at once. With GeoJS, while a setting needs the answer, such as a // answers at once. With GeoJS, while a setting needs the answer, such as a
// country list or a biased threshold, a new client's request waits for it. // country list or a biased threshold, a new client's request waits for it,
// ctx is the request's own context. // unless refusedWhateverTheAnswer reports true. ctx is the request's own
func (rq *request) lookUp(ctx context.Context) { // context.
func (rq *request) lookUp(ctx context.Context, now time.Time) {
if rq.h.config.LookupSource == "off" || !canBePlaced(rq.client) { if rq.h.config.LookupSource == "off" || !canBePlaced(rq.client) {
return return
} }
if rq.h.config.LookupSource == "file" { client := rq.h.clientGroup(rq.client)
rq.lookupAnswer = rq.h.lookupFile.LookUp(rq.h.clientGroup(rq.client))
} else { switch {
rq.lookupAnswer = rq.h.geojs.LookUp(ctx, rq.h.clientGroup(rq.client)) 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.lookedUp = true
@@ -52,8 +59,8 @@ func (h *handler) addLookup(answer lookup.Answer) {
answer.ASN, answer.ASName, answer.Country) answer.ASN, answer.ASName, answer.Country)
} }
// setLookupHeaders sets the headers in which the app is passed the // setLookupHeaders sets the headers that give the client's AS number and
// client's AS number and country, leaving out one that is unknown. // country, leaving out one that is unknown.
func setLookupHeaders(header http.Header, asn, country string) { func setLookupHeaders(header http.Header, asn, country string) {
if asn != "" { if asn != "" {
header.Set(asnHeader, asn) header.Set(asnHeader, asn)
@@ -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"
@@ -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)
+432
View File
@@ -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",
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.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,
deniedCountries: "kp",
})
// GeoJS fails, so the client counts as coming from an unknown country,
// which SWW_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 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,29 @@
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 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},
@@ -76,11 +72,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 +124,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) {
@@ -1,16 +1,13 @@
// Package proxy runs the checks on each request and writes its request log // Package discriminator is the server the discriminator command runs. It answers
// line. New passes each request it lets through to the app and the app's // rproxy's questions, takes rproxy's reports and answers its own endpoints, and
// answer back, unchanged, within the size and time limits. NewServeWAF instead // writes a log line for each.
// answers rproxy's question about each request, and takes rproxy's report once package discriminator
// the visitor's answer has ended, which writes the request a second log line.
package proxy
import ( import (
"fmt"
"io" "io"
"log"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
lru "github.com/hashicorp/golang-lru/v2" lru "github.com/hashicorp/golang-lru/v2"
@@ -27,49 +24,47 @@ import (
"sneak.berlin/go/smallwebwaf/internal/waf" "sneak.berlin/go/smallwebwaf/internal/waf"
) )
// How smallwebwaf keeps connections to the app open between requests. // adminPrefix starts the path of each of discriminator's own endpoints. The app never
const ( // sees a path under it.
appIdleConns = 100 const adminPrefix = "/_sww/"
appIdleConnTimeout = 90 * time.Second
)
// adminPrefix starts the path of every request for smallwebwaf itself, // HealthPath is the health endpoint of discriminator and of rproxy, which the
// which never reaches the app. // container's health check asks.
const adminPrefix = "/_smallwebwaf/" const HealthPath = "/_sww/healthz"
// 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 // MetricsPath is where the metrics are, for a request that carries
// SWWAF_METRICS_TOKEN. // SWW_METRICS_TOKEN.
const MetricsPath = "/_smallwebwaf/metrics" const MetricsPath = "/_sww/metrics"
// BansPath is where an admin lists and adds bans, and, followed by / and // BansPath is where an admin lists and adds bans, and, followed by / and
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN. // a client's address, lifts them, with SWW_ADMIN_TOKEN.
const BansPath = "/_smallwebwaf/bans" const BansPath = "/_sww/bans"
// ClientsPath is where an admin asks what smallwebwaf knows of a client, // ClientsPath is where an admin asks what discriminator knows of a client, by
// by the client's address after it, with SWWAF_ADMIN_TOKEN. // the client's address after it, with SWW_ADMIN_TOKEN.
const ClientsPath = "/_smallwebwaf/clients/" const ClientsPath = "/_sww/clients/"
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
Config *config.Config Config *config.Config
// RequestLog receives one JSON line per request, and from servewaf a second // RequestLog receives two JSON lines per question, one at its decision and
// when the request's report comes. // one when its report comes, and one per request to discriminator's own
// endpoints.
RequestLog io.Writer RequestLog io.Writer
// ProcessLog receives the process's own messages. // ProcessLog receives the process's own messages.
ProcessLog *slog.Logger ProcessLog *slog.Logger
// GeoJSURL is where clients' AS numbers and countries are looked up // GeoJSURL is where clients' AS numbers and countries are looked up
// while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL. // while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL.
GeoJSURL string GeoJSURL string
// AbuseIPDBURL is where clients are checked with AbuseIPDB while // AbuseIPDBURL is where clients are checked with AbuseIPDB while
// SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL. // SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
AbuseIPDBURL string AbuseIPDBURL string
// LookupFile is the lookup database they are looked up in while // LookupFile is the lookup database they are looked up in while
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise. // SWW_LOOKUP_SOURCE is file, and nil otherwise.
LookupFile *lookup.File LookupFile *lookup.File
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
// 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 // Now tells the time by which requests are counted for the rate
// limits, bans are made and run out, and GeoJS's answers are kept, // limits, bans are made and run out, and GeoJS's answers are kept,
// normally time.Now in UTC, the time the state files give. // normally time.Now in UTC, the time the state files give.
@@ -77,21 +72,20 @@ type Params struct {
// Rules are the rule files' rules, which each request is checked // Rules are the rule files' rules, which each request is checked
// against. // against.
Rules *rules.Files Rules *rules.Files
// Alerts receive the alert for each ban the proxy makes or makes // Alerts receive the alert for each ban discriminator makes or makes
// permanent, for each count over an anomaly threshold, for each request // permanent, for each count over an anomaly threshold, for each request
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or // whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
// AbuseIPDB lists, for each request the Core Rule Set scores at or over // AbuseIPDB lists, for each request the Core Rule Set scores at or over
// SWWAF_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list // SWW_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing, // failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
// or the day's AbuseIPDB checks used up. // or the day's AbuseIPDB checks used up.
Alerts *alerts.Queue Alerts *alerts.Queue
} }
// Server is the server smallwebwaf runs, with the parts of the proxy // Server is the server discriminator runs, with the parts whose state the state
// whose state the state files keep, the lookup database, nil unless // files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
// SWWAF_LOOKUP_SOURCE is file, the lists fetched from URLs, which its Run // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
// fetches, the DNSBL zones' verdicts, AbuseIPDB's scores and checks // AbuseIPDB's scores and checks spent, and the metrics.
// spent, and the metrics.
type Server struct { type Server struct {
*http.Server *http.Server
@@ -106,41 +100,24 @@ type Server struct {
Metrics *metrics.Metrics 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 {
h := newHandler(params)
return h.server(&http.Server{
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),
})
}
// newHandler returns the handler with what every request shares, as the // newHandler returns the handler with what every request shares, as the
// settings in params make it. // settings in params make it.
func newHandler(params Params) *handler { func newHandler(params Params) *handler {
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName) m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName)
lists, dnsbl, abuseIPDB := newReputation(params, m) 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{ h := &handler{
config: params.Config, config: params.Config,
requestLog: params.RequestLog, requestLog: params.RequestLog,
processLog: params.ProcessLog, processLog: params.ProcessLog,
errorLog: errorLog,
transport: newTransport(),
now: params.Now, now: params.Now,
metrics: m, metrics: m,
awaitingReport: awaitingReport,
limiter: ratelimit.New(ratelimit.Limits{ limiter: ratelimit.New(ratelimit.Limits{
PerMinute: params.Config.RateLimitPerMinute, PerMinute: params.Config.RateLimitPerMinute,
PerHour: params.Config.RateLimitPerHour, PerHour: params.Config.RateLimitPerHour,
@@ -172,7 +149,7 @@ func newHandler(params Params) *handler {
dnsbl: dnsbl, dnsbl: dnsbl,
abuseIPDB: abuseIPDB, abuseIPDB: abuseIPDB,
rules: params.Rules, rules: params.Rules,
coreRuleSet: newCoreRuleSet(params.Config), coreRuleSet: params.CoreRuleSet,
alerts: params.Alerts, alerts: params.Alerts,
} }
h.geojs = lookup.New(lookup.Params{ h.geojs = lookup.New(lookup.Params{
@@ -198,7 +175,7 @@ func newHandler(params Params) *handler {
// newReputation returns the lists fetched from URLs, the DNSBL zones' // newReputation returns the lists fetched from URLs, the DNSBL zones'
// verdicts and AbuseIPDB's scores, as the settings in params name them, // verdicts and AbuseIPDB's scores, as the settings in params name them,
// with none fetched, asked for or checked yet, and adds their metrics to // with none fetched, asked for or checked yet, and adds their metrics to
// m, AbuseIPDB's while SWWAF_ABUSEIPDB_KEY is set. // m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set.
func newReputation( func newReputation(
params Params, m *metrics.Metrics, params Params, m *metrics.Metrics,
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) { ) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
@@ -230,36 +207,42 @@ func newReputation(
return lists, dnsbl, abuseIPDB return lists, dnsbl, abuseIPDB
} }
// newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL, // NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. // up to SWW_WAF_BODY_LIMIT, with the exclusions in
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet { // SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
if cfg.WAFMode == config.WAFModeOff { // reads at most QuestionBodyMaxBytes less one byte: a question's body of
return nil // 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{ coreRuleSet, err := waf.New(waf.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: cfg.WAFBodyLimit, BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
}) })
if err != nil { if err != nil {
// The Core Rule Set is built in, and the settings cannot break it: return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
// 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 if cfg.WAFMode == config.WAFModeOff {
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
} }
// handler is the proxy. It holds what every request shares; what belongs return coreRuleSet, nil
// to one request is in a request. }
// handler is discriminator's handler. It holds what every request shares; what
// belongs to one request is in a request.
type handler struct { type handler struct {
config *config.Config config *config.Config
requestLog io.Writer requestLog io.Writer
processLog *slog.Logger processLog *slog.Logger
errorLog *log.Logger
transport http.RoundTripper
now func() time.Time now func() time.Time
metrics *metrics.Metrics metrics *metrics.Metrics
limiter *ratelimit.Limiter limiter *ratelimit.Limiter
@@ -273,88 +256,7 @@ type handler struct {
rules *rules.Files rules *rules.Files
coreRuleSet *waf.CoreRuleSet coreRuleSet *waf.CoreRuleSet
alerts *alerts.Queue alerts *alerts.Queue
// awaitingReport holds, in servewaf, the decision on each answered // awaitingReport holds the decision on each answered question by its id
// question by its id until its report comes, and nil for a question still // until its report comes, and nil for a question still being handled.
// being handled.
awaitingReport *lru.Cache[string, *decision] awaitingReport *lru.Cache[string, *decision]
} }
// 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.answerHealth()
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())
}
// server returns the Server of h, which listens at SWWAF_LISTEN_ADDR and
// logs through h, with what httpServer sets.
func (h *handler) server(httpServer *http.Server) *Server {
httpServer.Addr = h.config.ListenAddr
httpServer.ErrorLog = h.errorLog
return &Server{
Server: httpServer,
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,
}
}
@@ -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,86 @@ 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" deniedCountries = "SWW_DENIED_COUNTRIES"
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE" allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY" banResponse = "SWW_BAN_RESPONSE"
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS" limitBanDuration = "SWW_LIMIT_BAN_DURATION"
lookupSource = "SWWAF_LOOKUP_SOURCE" limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
lookupDBPath = "SWWAF_LOOKUP_DB_PATH" maxBanDuration = "SWW_MAX_BAN_DURATION"
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT" maxBans = "SWW_MAX_BANS"
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS" banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
deniedCountries = "SWWAF_DENIED_COUNTRIES" instanceName = "SWW_INSTANCE_NAME"
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES" logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
banResponse = "SWWAF_BAN_RESPONSE" attackBanDuration = "SWW_ATTACK_BAN_DURATION"
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION" rulesDir = "SWW_RULES_DIR"
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW" wafMode = "SWW_WAF_MODE"
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
maxBans = "SWWAF_MAX_BANS"
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 +175,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 +261,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 +271,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 +293,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 +309,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,38 +387,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() t.Helper()
return newServerWith(t, proxy.New, appURL, geojsURL, now, env) return newDiscriminatorWith(t, discriminator.New, geojsURL, now, env)
} }
// newServerWith is newProxy for the server build makes. // newDiscriminatorWith is newDiscriminator for the server build makes.
func newServerWith( func newDiscriminatorWith(
t *testing.T, build func(proxy.Params) *proxy.Server, t *testing.T, build func(discriminator.Params) *discriminator.Server,
appURL, geojsURL string, now func() time.Time, env map[string]string, geojsURL string, now func() time.Time, env map[string]string,
) (*proxy.Server, *output, *alerts.Queue) { ) (*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
@@ -344,13 +464,19 @@ func newServerWith(
} }
} }
server := build(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,
@@ -388,7 +514,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()
@@ -412,15 +538,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()
@@ -481,14 +607,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)
}
}
@@ -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)
} }
@@ -75,7 +75,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
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,7 +118,7 @@ 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{
@@ -151,7 +151,7 @@ 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 the country lists still refuse a path
// under a prefix. // under a 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)
@@ -1,4 +1,4 @@
package proxy package discriminator
import ( import (
"context" "context"
@@ -10,12 +10,12 @@ import (
"sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/reputation"
) )
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that // deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
// refuses the requests of a client a source lists. // refuses the requests of a client a source lists.
const deny = "deny" const deny = "deny"
// blocklistDenied notes the blocklists that list the client, as // blocklistDenied notes the blocklists that list the client, as
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny, // noteListed does, and reports whether SWW_BLOCKLIST_ACTION, being deny,
// refuses the request. Being limit, it lowers the client's limits instead // refuses the request. Being limit, it lowers the client's limits instead
// (see limitPercentages), and being log, it does nothing more. // (see limitPercentages), and being log, it does nothing more.
func (rq *request) blocklistDenied() bool { func (rq *request) blocklistDenied() bool {
@@ -43,7 +43,7 @@ func (rq *request) crowdSecBanned(now time.Time) bool {
} }
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as // dnsblDenied notes the DNSBL zones whose verdict lists the client, as
// noteListed does, and reports whether SWWAF_REPUTATION_ACTION, being // noteListed does, and reports whether SWW_REPUTATION_ACTION, being
// deny, refuses the request. Being limit, it lowers the client's limits // deny, refuses the request. Being limit, it lowers the client's limits
// instead (see limitPercentages), and being log, it does nothing more. A // instead (see limitPercentages), and being log, it does nothing more. A
// zone without a verdict on the client is asked about it in the // zone without a verdict on the client is asked about it in the
@@ -59,8 +59,8 @@ func (rq *request) dnsblDenied(ctx context.Context) bool {
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when // abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
// its score of the client is a hit, and reports whether // its score of the client is a hit, and reports whether
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied // SWW_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A // 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 // 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 // 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 // wait for the answer. The score is then used for each address of the
@@ -81,7 +81,7 @@ func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
rq.abuseIPDBHit = true rq.abuseIPDBHit = true
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score}, rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE") "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
return rq.h.config.ReputationAction == deny return rq.h.config.ReputationAction == deny
} }
@@ -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 (
@@ -127,7 +127,7 @@ func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
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))
@@ -224,7 +224,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
}, },
{"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 +272,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 +290,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 +299,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 +370,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 +416,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)
@@ -513,6 +514,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 +532,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 +566,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 +609,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 +617,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 +739,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 +746,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 +800,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 +851,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 +860,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 +887,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 +922,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 +971,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 +993,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 +1007,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 +1017,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))
+462
View File
@@ -0,0 +1,462 @@
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
}
// 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 checks 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 {
action := rq.checkClient(ctx)
switch {
case action == "":
return nil
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 checks on the request's client, and returns the
// action of the first that refuses the request, or "" when none does. A
// client in SWW_ALLOW_NETS skips them, and is not looked up. For any
// other client, SWW_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 SWW_RATE_LIMIT_EXEMPT_NETS or the
// request's path is exempt under SWW_RATE_LIMIT_EXEMPT_PATHS, so that
// every other request is counted, each of them by the client's limit
// percentages, then SWW_TRAP_PATHS, then the rule files, then the header
// limits, and last the Core Rule Set. A request exempt from the rate limits
// is exempt from the byte limits too. ctx is the request's own context.
func (rq *request) checkClient(ctx context.Context) string {
cfg := rq.h.config
if isInside(rq.client, cfg.AllowNets) {
return ""
}
now := rq.h.now()
if isInside(rq.client, cfg.DenyNets) {
return requestlog.ActionDenied
}
if rq.banned(now) {
return requestlog.ActionBanned
}
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.
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 = rq.countedByRateLimits()
if rq.counted {
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
}
if rq.counted && rq.limitBroken(now) {
return requestlog.ActionRateLimited
}
if rq.trapPath(now) {
return requestlog.ActionBanned
}
action := rq.checkRules(now)
if action != "" {
return action
}
if rq.overHeaderLimits() {
return requestlog.ActionTooLarge
}
return rq.checkCoreRuleSet()
}
// countedByRateLimits reports whether the rate limits count the request: its
// client is not in 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)
}
// 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,7 @@
package proxy_test package discriminator_test
import ( import (
"io" "io"
"maps"
"math" "math"
"net/http" "net/http"
"reflect" "reflect"
@@ -11,7 +10,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 +18,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 +41,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 +66,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 +77,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 +104,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 +130,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 +144,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 +157,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 +181,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 +192,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 +213,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 +238,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 {
@@ -350,22 +276,3 @@ func wantTimings(t *testing.T, line logLine, want ...string) {
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"])
}
}
@@ -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")
@@ -101,7 +101,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 +122,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 +133,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 +185,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 +194,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 package discriminator
import ( import (
"slices" "slices"
@@ -10,7 +10,7 @@ import (
) )
// trapPath reports whether the request asks for a path in // trapPath reports whether the request asks for a path in
// SWWAF_TRAP_PATHS: its path as a path rule sees it, without the query, // 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 // 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 // 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 // netblock, or in observe mode raises the alert for the ban it would have
@@ -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,7 +26,7 @@ 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,
@@ -34,10 +34,9 @@ func TestAllowNetsSkipEveryCheckButTheSizeLimit(t *testing.T) {
denyNets: fromKP, denyNets: fromKP,
deniedCountries: "kp", deniedCountries: "kp",
rateLimitPerMinute: "1", rateLimitPerMinute: "1",
requestMaxBytes: "1K",
}) })
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one // Neither SWW_DENY_NETS, the country lists nor the limit of one
// request a minute refuses the client, and its country is not looked // request a minute refuses the client, and its country is not looked
// up. // up.
wantAnswers(t, addr, out, []sentRequest{ wantAnswers(t, addr, out, []sentRequest{
@@ -49,14 +48,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 +73,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
@@ -101,13 +92,9 @@ func TestDenyNetsRefuseBeforeTheLookupAndTheBody(t *testing.T) {
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())
} }
@@ -170,9 +157,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 +167,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)
} }
} }
@@ -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.
@@ -104,7 +104,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)
+4 -4
View File
@@ -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"`
@@ -221,12 +221,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
+3 -3
View File
@@ -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",
}, },
}) })
+14 -6
View 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
} }
+35 -2
View File
@@ -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")
+1 -1
View File
@@ -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
+4 -4
View File
@@ -41,7 +41,7 @@ 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 the country lists refused, by the client's country.",
[]string{"country"}) []string{"country"})
@@ -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{},
+63 -74
View File
@@ -1,4 +1,4 @@
// Package metrics keeps smallwebwaf's Prometheus metrics, as // Package metrics keeps sww's Prometheus metrics, as
// docs/metrics.md lists them, and serves them in the Prometheus text // docs/metrics.md lists them, and serves them in the Prometheus text
// format, as the "Metrics endpoint" section of SPEC.md requires. No // format, as the "Metrics endpoint" section of SPEC.md requires. No
// metric carries a client's address. // metric carries a client's address.
@@ -22,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
@@ -35,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
@@ -63,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 {
@@ -77,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),
} }
@@ -143,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,
@@ -165,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 {
@@ -175,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())
@@ -183,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())
@@ -191,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())
@@ -204,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())
@@ -218,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())
@@ -252,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())
@@ -279,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})
@@ -298,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,
@@ -317,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}
@@ -325,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())
@@ -352,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,
@@ -395,15 +391,11 @@ 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, limit, duration, upstreamDuration) m.count(line, duration, 0)
} }
// RequestAllowed counts a question of rproxy's, answered allow, as no longer // RequestAllowed counts a question of rproxy's, answered allow, as no longer
@@ -412,12 +404,13 @@ func (m *Metrics) RequestAllowed() {
m.inFlight.Dec() m.inFlight.Dec()
} }
// RequestReported counts a request rproxy passed to the app, as RequestEnded // RequestReported counts a request rproxy passed to the app, from what its
// does, from what its report says, which line holds. // 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( func (m *Metrics) RequestReported(
line *requestlog.Line, duration, upstreamDuration time.Duration, line *requestlog.Line, duration, upstreamDuration time.Duration,
) { ) {
m.count(line, "", duration, upstreamDuration) m.count(line, duration, upstreamDuration)
} }
// 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
@@ -435,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()
@@ -450,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()
} }
@@ -475,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()
} }
@@ -486,9 +478,10 @@ 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 describes. // count counts a request that has ended, as RequestEnded and RequestReported
// describe.
func (m *Metrics) count( func (m *Metrics) count(
line *requestlog.Line, limit string, duration, upstreamDuration time.Duration, line *requestlog.Line, duration, upstreamDuration time.Duration,
) { ) {
class := statusClass(line.Status) class := statusClass(line.Status)
m.requests.WithLabelValues(class, line.Action).Inc() m.requests.WithLabelValues(class, line.Action).Inc()
@@ -500,10 +493,6 @@ func (m *Metrics) count(
m.upstreamDuration.Observe(upstreamDuration.Seconds()) m.upstreamDuration.Observe(upstreamDuration.Seconds())
} }
if limit != "" {
m.sizeAndTimeLimitHits.WithLabelValues(limit).Inc()
}
if line.Country != "" { if line.Country != "" {
m.countries.add(line.Country, line) m.countries.add(line.Country, line)
} }
@@ -517,7 +506,7 @@ func (m *Metrics) count(
// zone, or of the checks of clients with AbuseIPDB, which count tells. // zone, or of the checks of clients with AbuseIPDB, which count tells.
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) { func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{ m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
Name: "smallwebwaf_reputation_queries_total", Name: "sww_reputation_queries_total",
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.", Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
ConstLabels: source, ConstLabels: source,
}, func() float64 { }, func() float64 {
@@ -530,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,
-165
View File
@@ -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)
}
-56
View File
@@ -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())
}
}
-162
View File
@@ -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:])
}
-545
View File
@@ -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: banResponseClose,
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))
}
-556
View File
@@ -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)
}
}
-936
View File
@@ -1,936 +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
}
// decision is what the checks note of a request: its log line, and what
// counting the request needs once it has ended. servewaf keeps a copy of it
// 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, in servewaf, the host the visitor asked for, which the
// alerts name, since one servewaf serves many sites. It is "" otherwise.
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
// 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 on its way through smallwebwaf, from the moment
// its headers have been read to its log line.
type request struct {
decision
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
peer netip.Addr
peerTrusted bool
// tokenRefused is true for a request refused for a missing or wrong
// token, an offence its client's history counts.
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
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 works out the client from the TCP peer
// and X-Forwarded-For, and goes on as startRequest does.
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
peer := peerAddress(r)
trusted := h.config.TrustedProxies
client := clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted)
return h.startRequest(w, r, client, peer, isInside(peer, trusted))
}
// 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. peerTrusted says whether to believe
// the request's id and scheme, which r's headers give.
func (h *handler) startRequest(
w http.ResponseWriter, r *http.Request, client, peer netip.Addr, peerTrusted 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, 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,
},
},
h: h,
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
peer: peer,
peerTrusted: peerTrusted,
start: start,
}
// 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. 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, limit, duration, upstreamDuration)
}
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() {
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{
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))
}
// 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()
}
}
-55
View File
@@ -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"))
}
}
-453
View File
@@ -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)
}
+2 -2
View File
@@ -23,8 +23,8 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
{Forwarded: true, Status: 502, ResponseBytes: 12}, {Forwarded: true, Status: 502, ResponseBytes: 12},
// Closed without an answer: refused, and no response. // Closed without an answer: refused, and no response.
{Refused: true, Status: 0}, {Refused: true, Status: 0},
// Answered 404 at smallwebwaf's own endpoints: neither forwarded // Answered 404 for a path under /_sww/: neither forwarded nor
// nor refused. // refused.
{Status: 404}, {Status: 404},
} { } {
limiter.AddToHistory(client, start.Add(time.Duration(i)*time.Minute), r) limiter.AddToHistory(client, start.Add(time.Duration(i)*time.Minute), r)
+39 -23
View File
@@ -2,7 +2,7 @@
// and bytes counted over a minute, an hour and a day, as the "Counting // and bytes counted over a minute, an hour and a day, as the "Counting
// method" section of SPEC.md describes, which tell when a request takes // method" section of SPEC.md describes, which tell when a request takes
// the client over a rate limit or a byte limit, and each client's history // the client over a rate limit or a byte limit, and each client's history
// since it was first seen. At most SWWAF_MAX_TRACKED_CLIENTS clients are // since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
// kept, in memory, and written to clients.json and read from it by the // kept, in memory, and written to clients.json and read from it by the
// state package. // state package.
package ratelimit package ratelimit
@@ -25,7 +25,7 @@ const (
KindRequests = "requests" KindRequests = "requests"
// KindBytes is a byte limit, on a client's bytes. // KindBytes is a byte limit, on a client's bytes.
KindBytes = "bytes" KindBytes = "bytes"
// KindRefusals is the error burst, on a client's requests smallwebwaf // KindRefusals is the error burst, on a client's requests sww
// refused after a rule file match or for a missing or wrong token. // refused after a rule file match or for a missing or wrong token.
KindRefusals = "refusals" KindRefusals = "refusals"
) )
@@ -92,10 +92,9 @@ type History struct {
ASName string `json:"as_name,omitempty"` ASName string `json:"as_name,omitempty"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
LookedUp time.Time `json:"looked_up,omitzero"` LookedUp time.Time `json:"looked_up,omitzero"`
// Requests are all the client's requests: Forwarded those passed to // Requests are all the client's requests: Forwarded those discriminator allowed,
// the app, Refused those refused before anything reached it, a 401 at // Refused those it refused, and neither those for a path under
// smallwebwaf's own endpoints included, and neither the others // /_sww/.
// smallwebwaf answered there.
Requests int64 `json:"requests"` Requests int64 `json:"requests"`
Forwarded int64 `json:"forwarded"` Forwarded int64 `json:"forwarded"`
Refused int64 `json:"refused"` Refused int64 `json:"refused"`
@@ -124,21 +123,20 @@ type Offences struct {
// Limit is its requests that broke a rate limit, a byte limit or the // Limit is its requests that broke a rate limit, a byte limit or the
// error burst, Attack those that were a clear sign of attack, a match // error burst, Attack those that were a clear sign of attack, a match
// of a ban rule or a request for a trap path, RuleBlocked those a block // of a ban rule or a request for a trap path, RuleBlocked those a block
// rule refused, WAFBlocked those the Core Rule Set refused, and // rule refused, and WAFBlocked those the Core Rule Set refused.
// TokenRefused those refused for a missing or wrong token.
Limit int64 `json:"limit"` Limit int64 `json:"limit"`
Attack int64 `json:"attack"` Attack int64 `json:"attack"`
RuleBlocked int64 `json:"rule_blocked"` RuleBlocked int64 `json:"rule_blocked"`
WAFBlocked int64 `json:"waf_blocked"` WAFBlocked int64 `json:"waf_blocked"`
TokenRefused int64 `json:"token_refused"` // TokenRefused is what a clients.json written by an earlier version gives,
// which must still load. Nothing adds to it.
TokenRefused int64 `json:"token_refused,omitempty"`
} }
// Request is what a client's history keeps of one of its requests. // Request is what a client's history keeps of one of its requests.
type Request struct { type Request struct {
// Forwarded is true for a request passed to the app, Refused for one // Forwarded is true for a request discriminator allowed, and Refused for one it
// refused before anything reached it, a 401 at smallwebwaf's own // refused. Both are false for a request for a path under /_sww/.
// endpoints included. Both are false for any other request smallwebwaf
// answered there.
Forwarded bool Forwarded bool
Refused bool Refused bool
// Status is what the client was sent, 0 if nothing was. // Status is what the client was sent, 0 if nothing was.
@@ -149,18 +147,16 @@ type Request struct {
ResponseBytes int64 ResponseBytes int64
// BrokeLimit is true for a request that broke a rate limit, a byte // BrokeLimit is true for a request that broke a rate limit, a byte
// limit or the error burst, Attack for one that matched a ban rule or // limit or the error burst, Attack for one that matched a ban rule or
// asked for a trap path, RuleBlocked for one a block rule refused, // asked for a trap path, RuleBlocked for one a block rule refused, and
// WAFBlocked for one the Core Rule Set refused, and TokenRefused for // WAFBlocked for one the Core Rule Set refused.
// one refused for a missing or wrong token.
BrokeLimit bool BrokeLimit bool
Attack bool Attack bool
RuleBlocked bool RuleBlocked bool
WAFBlocked bool WAFBlocked bool
TokenRefused bool
} }
// New returns a Limiter for limits, with no client counted yet, whose // New returns a Limiter for limits, with no client counted yet, whose
// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past // table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
// it, the least recently seen client is dropped, with its history, and // it, the least recently seen client is dropped, with its history, and
// starts afresh if it comes back. // starts afresh if it comes back.
func New(limits Limits, maxClients int) *Limiter { func New(limits Limits, maxClients int) *Limiter {
@@ -228,6 +224,30 @@ func (l *Limiter) Count(
return l.count(client, now, 1, 0, percent) return l.count(client, now, 1, 0, percent)
} }
// OverARateLimit reports whether one more request from client at now would
// take it over a whole rate limit. A client's percentage can only lower a
// limit, so that request is over the limit at any percentage. It counts
// nothing, and does not make the client the most recently seen.
func (l *Limiter) OverARateLimit(client netip.Prefix, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
c, seen := l.clients.Peek(client)
if !seen {
return false
}
buckets := c.buckets()
for i, w := range l.windows {
if w.limit > 0 && buckets[i].Add(now, w.length, 0)+1 > float64(w.limit) {
return true
}
}
return false
}
// CountBytes counts bytes, those of a request from client that has ended, // CountBytes counts bytes, those of a request from client that has ended,
// at now, in every window, and returns the client's counts in each window. // at now, in every window, and returns the client's counts in each window.
// It reports whether the bytes take the client over a byte limit, of which // It reports whether the bytes take the client over a byte limit, of which
@@ -238,7 +258,7 @@ func (l *Limiter) CountBytes(
return l.count(client, now, 0, bytes, percent) return l.count(client, now, 0, bytes, percent)
} }
// CountRefusal counts a request from client at now that smallwebwaf // CountRefusal counts a request from client at now that sww
// refused after a rule file or Core Rule Set match or for a missing or // refused after a rule file or Core Rule Set match or for a missing or
// wrong token, and reports whether the client's refusals in the minute // wrong token, and reports whether the client's refusals in the minute
// that ends at now, this one included, are more than threshold, which // that ends at now, this one included, are more than threshold, which
@@ -310,10 +330,6 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
if r.WAFBlocked { if r.WAFBlocked {
h.Offences.WAFBlocked++ h.Offences.WAFBlocked++
} }
if r.TokenRefused {
h.Offences.TokenRefused++
}
} }
// AddLookup gives client's history its AS number, AS name and country, as // AddLookup gives client's history its AS number, AS name and country, as
+45 -1
View File
@@ -13,7 +13,7 @@ import (
const limit = 3 const limit = 3
// tableSize is the most clients the tests' tables hold, the default of // tableSize is the most clients the tests' tables hold, the default of
// SWWAF_MAX_TRACKED_CLIENTS. // SWW_MAX_TRACKED_CLIENTS.
const tableSize = 20000 const tableSize = 20000
// whole is the percentage of each limit a client gets when nothing lowers // whole is the percentage of each limit a client gets when nothing lowers
@@ -26,6 +26,50 @@ const (
hour = "hour" hour = "hour"
) )
func TestOverARateLimitSaysWhatTheNextRequestWouldDoAndCountsNothing(t *testing.T) {
t.Parallel()
client := netip.MustParsePrefix("203.0.113.9/32")
now := midnight()
limiter := ratelimit.New(ratelimit.Limits{PerMinute: limit, PerDay: limit + 1},
tableSize)
if limiter.OverARateLimit(client, now) {
t.Error("a client never seen is over a rate limit")
}
if limiter.Len() != 0 {
t.Error("asking about a client never seen added it to the table")
}
for range limit {
if limiter.OverARateLimit(client, now) {
t.Error("a client with room for another request is over a rate limit")
}
wantCount(t, limiter, client, now, "")
}
if !limiter.OverARateLimit(client, now) {
t.Error("a client at its limit is not over a rate limit")
}
held, _ := limiter.Client(client)
if held.Minute.Current != limit {
t.Errorf("the client's minute holds %d requests, want %d", held.Minute.Current, limit)
}
wantCount(t, limiter, client, now, minute)
// A limit that is off is never gone over.
off := ratelimit.New(ratelimit.Limits{}, tableSize)
off.Count(client, now, whole)
if off.OverARateLimit(client, now) {
t.Error("a client is over a rate limit that is off")
}
}
func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) { func TestEachWindowRefusesAtItsLimitAndLetsTheClientBack(t *testing.T) {
t.Parallel() t.Parallel()
+9 -9
View File
@@ -1,5 +1,5 @@
// Package remotelog sends the lines smallwebwaf writes on stdout to the // Package remotelog sends the lines sww writes on stdout to the
// remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section // remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section
// of SPEC.md describes: each line as the message of an RFC 5424 syslog // of SPEC.md describes: each line as the message of an RFC 5424 syslog
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow // record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
// or unreachable endpoint never holds up a request or stdout. // or unreachable endpoint never holds up a request or stdout.
@@ -24,7 +24,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The forms of SWWAF_LOG_REMOTE_URL, by its scheme. // The forms of SWW_LOG_REMOTE_URL, by its scheme.
const ( const (
SchemeUDP = "syslog+udp" SchemeUDP = "syslog+udp"
SchemeTCP = "syslog+tcp" SchemeTCP = "syslog+tcp"
@@ -56,18 +56,18 @@ const (
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// URL is the endpoint (SWWAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or // URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
// SchemeTLS, a host and a port. // SchemeTLS, a host and a port.
URL *url.URL URL *url.URL
// RootCAs are the certificates a SchemeTLS endpoint's certificate // RootCAs are the certificates a SchemeTLS endpoint's certificate
// must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's. // must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
RootCAs *x509.CertPool RootCAs *x509.CertPool
// Buffer is the most lines held while they wait to be sent // Buffer is the most lines held while they wait to be sent
// (SWWAF_LOG_REMOTE_BUFFER). // (SWW_LOG_REMOTE_BUFFER).
Buffer int Buffer int
// Facility is the number of the records' syslog facility // Facility is the number of the records' syslog facility
// (SWWAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME // (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME
// (SWWAF_LOG_REMOTE_APP_NAME). // (SWW_LOG_REMOTE_APP_NAME).
Facility int Facility int
AppName string AppName string
} }
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
} }
} }
processLog.Warn("sending to SWWAF_LOG_REMOTE_URL failed", processLog.Warn("sending to SWW_LOG_REMOTE_URL failed",
"error", err.Error(), "connecting_again_in", delay.String()) "error", err.Error(), "connecting_again_in", delay.String())
select { select {
+6 -6
View File
@@ -44,7 +44,7 @@ import (
const ( const (
// started is the time a record made as a test starts gives. // started is the time a record made as a test starts gives.
started = "2000-01-01T00:00:00.000Z" started = "2000-01-01T00:00:00.000Z"
appName = "fsn1app1/gitea" appName = "fsn1app1/blog"
// local0 is the number of the default facility, and local0Info the // local0 is the number of the default facility, and local0Info the
// priority of its records. // priority of its records.
local0 = 16 local0 = 16
@@ -94,7 +94,7 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
endpoint := listen(t) endpoint := listen(t)
endpointParams := params(remotelog.SchemeTCP, endpoint.Addr()) endpointParams := params(remotelog.SchemeTCP, endpoint.Addr())
endpointParams.Facility = 19 // local3 endpointParams.Facility = 19 // local3
endpointParams.AppName = "gitea" endpointParams.AppName = "blog"
sender, _, _ := run(t, endpointParams) sender, _, _ := run(t, endpointParams)
_, _ = sender.Write([]byte("first\nsecond\n")) _, _ = sender.Write([]byte("first\nsecond\n"))
@@ -103,8 +103,8 @@ func TestRecordsOverTCPAreOctetCountedWithTheirFacility(t *testing.T) {
wantCounts(t, sender, 2, 0, 0) wantCounts(t, sender, 2, 0, 0)
frames := bufio.NewReader(accept(t, endpoint)) frames := bufio.NewReader(accept(t, endpoint))
wantFrame(t, frames, record(t, "<158>", "gitea", "first")) wantFrame(t, frames, record(t, "<158>", "blog", "first"))
wantFrame(t, frames, record(t, "<158>", "gitea", "second")) wantFrame(t, frames, record(t, "<158>", "blog", "second"))
}) })
} }
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
var fields map[string]any var fields map[string]any
err := json.Unmarshal([]byte(line), &fields) err := json.Unmarshal([]byte(line), &fields)
if err != nil || fields["msg"] != "sending to SWWAF_LOG_REMOTE_URL failed" { if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
t.Fatalf("logged %q", line) t.Fatalf("logged %q", line)
} }
@@ -612,7 +612,7 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
template := &x509.Certificate{ template := &x509.Certificate{
SerialNumber: big.NewInt(1), SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "smallwebwaf test CA"}, Subject: pkix.Name{CommonName: "sww test CA"},
NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC), NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC),
NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
IsCA: true, IsCA: true,
+5 -5
View File
@@ -58,17 +58,17 @@ type Checks struct {
// AbuseIPDBParams are what NewAbuseIPDB needs. // AbuseIPDBParams are what NewAbuseIPDB needs.
type AbuseIPDBParams struct { type AbuseIPDBParams struct {
// URL is where clients are checked, normally AbuseIPDBURL, with Key, // URL is where clients are checked, normally AbuseIPDBURL, with Key,
// the account's key (SWWAF_ABUSEIPDB_KEY). // the account's key (SWW_ABUSEIPDB_KEY).
URL string URL string
Key string Key string
// MinScore is the least score that is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), // MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE),
// and DailyBudget the most checks made in a day, in UTC // and DailyBudget the most checks made in a day, in UTC
// (SWWAF_ABUSEIPDB_DAILY_BUDGET). // (SWW_ABUSEIPDB_DAILY_BUDGET).
MinScore int64 MinScore int64
DailyBudget int DailyBudget int
// CacheTTL is how long a score is used after it was fetched // CacheTTL is how long a score is used after it was fetched
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take // (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take
// (SWWAF_REPUTATION_TIMEOUT). // (SWW_REPUTATION_TIMEOUT).
CacheTTL time.Duration CacheTTL time.Duration
Timeout time.Duration Timeout time.Duration
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
+2 -2
View File
@@ -397,7 +397,7 @@ func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) {
"failures_total": "1", "failures_total": "1",
"daily_budget_remaining": "3", "daily_budget_remaining": "3",
} { } {
line := "\nsmallwebwaf_reputation_" + series + line := "\nsww_reputation_" + series +
`{instance="app",source="abuseipdb"} ` + want + "\n" `{instance="app",source="abuseipdb"} ` + want + "\n"
if !strings.Contains(scraped, line) { if !strings.Contains(scraped, line) {
t.Errorf("metrics\n%s\nwant%s", scraped, line) t.Errorf("metrics\n%s\nwant%s", scraped, line)
@@ -581,7 +581,7 @@ func wantScore(
// hitFrom is checker's Hit for a request from address, offender or not. // hitFrom is checker's Hit for a request from address, offender or not.
// Its client is address for an IPv4 address, and its /64 for an IPv6 one, // Its client is address for an IPv4 address, and its /64 for an IPv6 one,
// as smallwebwaf counts clients. // as sww counts clients.
func hitFrom( func hitFrom(
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool, t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
) (int64, bool) { ) (int64, bool) {
+7 -7
View File
@@ -53,15 +53,15 @@ type Verdict struct {
// DNSBLParams are what NewDNSBL needs. // DNSBLParams are what NewDNSBL needs.
type DNSBLParams struct { type DNSBLParams struct {
// Zones are the DNSBL zones clients are asked about in // Zones are the DNSBL zones clients are asked about in
// (SWWAF_DNSBL_ZONES). // (SWW_DNSBL_ZONES).
Zones []string Zones []string
// Resolver is the resolver they are asked through // Resolver is the resolver they are asked through
// (SWWAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the // (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
// host's, as /etc/resolv.conf names it. // host's, as /etc/resolv.conf names it.
Resolver netip.AddrPort Resolver netip.AddrPort
// CacheTTL is how long a verdict is used after it was fetched // CacheTTL is how long a verdict is used after it was fetched
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take // (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take
// (SWWAF_REPUTATION_TIMEOUT). // (SWW_REPUTATION_TIMEOUT).
CacheTTL time.Duration CacheTTL time.Duration
Timeout time.Duration Timeout time.Duration
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
} }
} }
// Zones returns the zones, in the order SWWAF_DNSBL_ZONES names them. // Zones returns the zones, in the order SWW_DNSBL_ZONES names them.
func (d *DNSBL) Zones() []string { func (d *DNSBL) Zones() []string {
return slices.Clone(d.params.Zones) return slices.Clone(d.params.Zones)
} }
// ListedBy returns the zones whose verdict on addr, a client's address, // ListedBy returns the zones whose verdict on addr, a client's address,
// lists it, in the order SWWAF_DNSBL_ZONES names them, each with its key // lists it, in the order SWW_DNSBL_ZONES names them, each with its key
// masked, as config.MaskZoneKey masks it, since they go to the request // masked, as config.MaskZoneKey masks it, since they go to the request
// log, the alerts and the metrics. A verdict is used until CacheTTL has // log, the alerts and the metrics. A verdict is used until CacheTTL has
// passed since it was fetched. Each zone without one is asked about addr // passed since it was fetched. Each zone without one is asked about addr
@@ -210,7 +210,7 @@ func (d *DNSBL) Snapshot() []Verdict {
} }
// Load keeps verdicts, read from reputation.json, in place of those it // Load keeps verdicts, read from reputation.json, in place of those it
// keeps, but for those of a zone SWWAF_DNSBL_ZONES does not name, and, // keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and,
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or // past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
// more is neither used nor written, as for any verdict. // more is neither used nor written, as for any verdict.
func (d *DNSBL) Load(verdicts []Verdict) { func (d *DNSBL) Load(verdicts []Verdict) {
+5 -5
View File
@@ -40,8 +40,8 @@ const (
// zone and otherZone are the DNSBL zones the tests name. // zone and otherZone are the DNSBL zones the tests name.
zone = "dnsbl.example" zone = "dnsbl.example"
otherZone = "other.example" otherZone = "other.example"
// cacheTTL is the tests' SWWAF_REPUTATION_CACHE_TTL, and timeout their // cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their
// SWWAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf // SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
// can have Go's resolver wait for one server, so that it is the // can have Go's resolver wait for one server, so that it is the
// DNSBL's own timeout that ends a query, whatever that file says. // DNSBL's own timeout that ends a query, whatever that file says.
cacheTTL = 24 * time.Hour cacheTTL = 24 * time.Hour
@@ -311,7 +311,7 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) {
"queries_total" + `{instance="app",source="` + otherZone + `"}`: "1", "queries_total" + `{instance="app",source="` + otherZone + `"}`: "1",
"failures_total" + `{instance="app",source="` + otherZone + `"}`: "1", "failures_total" + `{instance="app",source="` + otherZone + `"}`: "1",
} { } {
line := "\nsmallwebwaf_reputation_" + series + " " + want + "\n" line := "\nsww_reputation_" + series + " " + want + "\n"
if !strings.Contains(scraped.Body.String(), line) { if !strings.Contains(scraped.Body.String(), line) {
t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line) t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line)
} }
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
dnsbl := reputation.NewDNSBL(p) dnsbl := reputation.NewDNSBL(p)
// The last verdict still in use, one fetched a TTL ago, and one of a // The last verdict still in use, one fetched a TTL ago, and one of a
// zone SWWAF_DNSBL_ZONES does not name. // zone SWW_DNSBL_ZONES does not name.
inUse := reputation.Verdict{ inUse := reputation.Verdict{
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true, Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
Fetched: now.Add(-cacheTTL + time.Nanosecond), Fetched: now.Add(-cacheTTL + time.Nanosecond),
@@ -471,7 +471,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.
} }
//nolint:paralleltest // one at a time, as the comment at the top of this file says //nolint:paralleltest // one at a time, as the comment at the top of this file says
func TestQueriesGoToTheResolverSWWAFDNSBLResolverNames(t *testing.T) { func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) {
resolver := &resolverStandIn{answers: map[string]answer{ resolver := &resolverStandIn{answers: map[string]answer{
listedName: {addrs: []string{listing}}, listedName: {addrs: []string{listing}},
}} }}
+13 -13
View File
@@ -1,10 +1,10 @@
// Package reputation fetches the lists the settings name by URL: the // Package reputation fetches the lists the settings name by URL: the
// blocklists of SWWAF_BLOCKLIST_URLS, the file of AS:percent lines // blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
// SWWAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec // SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
// engine SWWAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of // engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of
// each, whole, comment lines included, which is used while a fetch fails, // each, whole, comment lines included, which is used while a fetch fails,
// and when each was last tried. It also asks the DNSBL zones of // and when each was last tried. It also asks the DNSBL zones of
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks // SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
// clients with AbuseIPDB, and keeps their scores and the checks spent // clients with AbuseIPDB, and keeps their scores and the checks spent
// today. The state package writes all of these to reputation.json and // today. The state package writes all of these to reputation.json and
// reads them from it, so that a restart keeps them too. // reads them from it, so that a restart keeps them too.
@@ -66,18 +66,18 @@ type List struct {
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
// BlocklistURLs are the blocklists (SWWAF_BLOCKLIST_URLS), and // BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and
// ASNLimitPercentURL the file of AS:percent lines // ASNLimitPercentURL the file of AS:percent lines
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset. // (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
BlocklistURLs []string BlocklistURLs []string
ASNLimitPercentURL string ASNLimitPercentURL string
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while // CrowdSecDecisionsURL is the CrowdSec decision list, "" while
// SWWAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey // SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
// (SWWAF_CROWDSEC_LAPI_KEY). // (SWW_CROWDSEC_LAPI_KEY).
CrowdSecDecisionsURL string CrowdSecDecisionsURL string
CrowdSecKey string CrowdSecKey string
// Refresh is how long after a list was last fetched or tried it is // Refresh is how long after a list was last fetched or tried it is
// fetched again (SWWAF_BLOCKLIST_REFRESH), but for the CrowdSec decision // fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision
// list, which is fetched again crowdSecRefresh after. // list, which is fetched again crowdSecRefresh after.
Refresh time.Duration Refresh time.Duration
// Now tells the time, normally time.Now in UTC. // Now tells the time, normally time.Now in UTC.
@@ -152,7 +152,7 @@ func New(params Params) *Lists {
} }
// URLs returns the URL of every list: the blocklists' in the order // URLs returns the URL of every list: the blocklists' in the order
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL, then // SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then
// the CrowdSec decision list's. // the CrowdSec decision list's.
func (l *Lists) URLs() []string { func (l *Lists) URLs() []string {
urls := slices.Clone(l.params.BlocklistURLs) urls := slices.Clone(l.params.BlocklistURLs)
@@ -168,7 +168,7 @@ func (l *Lists) URLs() []string {
} }
// ListedBy returns the URLs of the blocklists whose copy lists addr, in // ListedBy returns the URLs of the blocklists whose copy lists addr, in
// the order SWWAF_BLOCKLIST_URLS names them. // the order SWW_BLOCKLIST_URLS names them.
func (l *Lists) ListedBy(addr netip.Addr) []string { func (l *Lists) ListedBy(addr netip.Addr) []string {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
@@ -371,7 +371,7 @@ func (l *Lists) due(listURL string) time.Time {
// fetch fetches the list at listURL, and notes the try. A good copy takes // fetch fetches the list at listURL, and notes the try. A good copy takes
// the place of the one held. A failure leaves that in use, and is counted, // the place of the one held. A failure leaves that in use, and is counted,
// logged and raised as a source_failure alert. A fetch cut off as ctx // logged and raised as a source_failure alert. A fetch cut off as ctx
// ends, as smallwebwaf stops, is no failure, but is still noted as a try, // ends, as sww stops, is no failure, but is still noted as a try,
// so that a restart waits for it: the server may have had its request. // so that a restart waits for it: the server may have had its request.
func (l *Lists) fetch(ctx context.Context, listURL string) { func (l *Lists) fetch(ctx context.Context, listURL string) {
lines, err := l.get(ctx, listURL) lines, err := l.get(ctx, listURL)
@@ -551,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
} }
// parsePercents reads the lines of the file of AS:percent lines, each an // parsePercents reads the lines of the file of AS:percent lines, each an
// AS number, : and a percentage, as SWWAF_ASN_LIMIT_PERCENT takes them. An // AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An
// AS number listed more than once gets the lowest of its percentages. // AS number listed more than once gets the lowest of its percentages.
func parsePercents(lines []string) (entries, error) { func parsePercents(lines []string) (entries, error) {
found := entries{percents: map[string]int64{}} found := entries{percents: map[string]int64{}}
+3 -3
View File
@@ -34,8 +34,8 @@ const (
dropURL = "https://lists.example/drop.txt" dropURL = "https://lists.example/drop.txt"
torURL = "https://lists.example/tor.txt" torURL = "https://lists.example/tor.txt"
asnURL = "https://lists.example/asn.txt" asnURL = "https://lists.example/asn.txt"
// refresh is the tests' SWWAF_BLOCKLIST_REFRESH, and cooldown their // refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their
// SWWAF_ALERT_COOLDOWN, longer than it. // SWW_ALERT_COOLDOWN, longer than it.
refresh = 24 * time.Hour refresh = 24 * time.Hour
cooldown = 48 * time.Hour cooldown = 48 * time.Hour
// drop is a blocklist as the Spamhaus DROP list is written, with an // drop is a blocklist as the Spamhaus DROP list is written, with an
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
}} }}
lists := start(t, servers, params(torURL, dropURL)) lists := start(t, servers, params(torURL, dropURL))
// In the order SWWAF_BLOCKLIST_URLS names them. // In the order SWW_BLOCKLIST_URLS names them.
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL) wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
wantListedBy(t, lists, "203.0.113.8", dropURL) wantListedBy(t, lists, "203.0.113.8", dropURL)
}) })
+78 -51
View File
@@ -1,6 +1,7 @@
// Package requestlog writes the lines smallwebwaf prints on stdout: one // Package requestlog writes the lines sww prints on stdout: one
// JSON object per request, or two from servewaf, marked "type":"request", and // JSON object per request, two from discriminator and a short one from rproxy,
// the process's own messages as JSON lines marked "type":"process". // marked "type":"request", and the process's own messages as JSON lines marked
// "type":"process".
package requestlog package requestlog
import ( import (
@@ -13,18 +14,13 @@ import (
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
) )
// The action a request line names: what smallwebwaf did with the // The action a request line names: what discriminator did with the request.
// request.
const ( const (
// ActionForward is a request passed to the app. // ActionForward is a request discriminator allowed, which rproxy passes to
// the app.
ActionForward = "forward" ActionForward = "forward"
// ActionTooLarge is a request or response over its size limit. // ActionTooLarge is a request over the header limits.
ActionTooLarge = "too_large" ActionTooLarge = "too_large"
// ActionTimedOut is a request or response that ran out of time.
ActionTimedOut = "timed_out"
// ActionUpstreamError is a request the app could not be reached
// for, or whose answer could not be passed on.
ActionUpstreamError = "upstream_error"
// ActionRateLimited is a request refused because it took its client // ActionRateLimited is a request refused because it took its client
// over a rate limit, which bans the client. // over a rate limit, which bans the client.
ActionRateLimited = "rate_limited" ActionRateLimited = "rate_limited"
@@ -37,17 +33,17 @@ const (
// rule. // rule.
ActionRuleBlocked = "rule_blocked" ActionRuleBlocked = "rule_blocked"
// ActionWAFBlocked is a request refused because the Core Rule Set // ActionWAFBlocked is a request refused because the Core Rule Set
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD. // scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
ActionWAFBlocked = "waf_blocked" ActionWAFBlocked = "waf_blocked"
// ActionDenied is a request refused because its client is in // ActionDenied is a request refused because its client is in
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny, // SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWWAF_REPUTATION_ACTION is deny. // SWW_REPUTATION_ACTION is deny.
ActionDenied = "denied" ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country. // ActionCountryDenied is a request refused for its client's country.
ActionCountryDenied = "country_denied" ActionCountryDenied = "country_denied"
// ActionAdmin is a request smallwebwaf answered at one of its own // ActionAdmin is a request discriminator answered at one of its own
// endpoints, under /_smallwebwaf/. // endpoints, or a question about a path under /_sww/.
ActionAdmin = "admin" ActionAdmin = "admin"
) )
@@ -60,14 +56,14 @@ const OffenceLimit = "limit"
// that broke the error burst. // that broke the error burst.
const LimitHitErrorBurst = "error_burst" const LimitHitErrorBurst = "error_burst"
// The servewaf_line a request line names, which says which of servewaf's two // The discriminator_line a request line names, which says which of
// lines about a question it is. // discriminator's two lines about a question it is.
const ( const (
// ServeWAFLineDecision is servewaf's line at its decision. // DiscriminatorLineDecision is discriminator's line at its decision.
ServeWAFLineDecision = "decision" DiscriminatorLineDecision = "decision"
// ServeWAFLineReport is servewaf's line when rproxy's report comes, with // DiscriminatorLineReport is discriminator's line when rproxy's report
// the decision's fields and the report's together. // comes, with the decision's fields and the report's together.
ServeWAFLineReport = "report" DiscriminatorLineReport = "report"
) )
// timeLayout is RFC 3339 with milliseconds. // timeLayout is RFC 3339 with milliseconds.
@@ -80,12 +76,12 @@ const timeLayout = "2006-01-02T15:04:05.000Z07:00"
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case //nolint:tagliatelle // docs/request-log.md names the fields in snake_case
type Line struct { type Line struct {
Type string `json:"type"` Type string `json:"type"`
// ServeWAFLine is ServeWAFLineDecision or ServeWAFLineReport on servewaf's // DiscriminatorLine is DiscriminatorLineDecision or DiscriminatorLineReport
// two lines about a question. // on discriminator's two lines about a question.
ServeWAFLine string `json:"servewaf_line,omitempty"` DiscriminatorLine string `json:"discriminator_line,omitempty"`
// The standard web log fields. Scheme is how the client reached // The standard web log fields. Scheme is how the client reached rproxy, or
// smallwebwaf, or the trusted proxy in front of it. // the trusted proxy in front of it.
Time string `json:"time"` Time string `json:"time"`
Instance string `json:"instance"` Instance string `json:"instance"`
ClientIP string `json:"client_ip"` ClientIP string `json:"client_ip"`
@@ -101,8 +97,8 @@ type Line struct {
Referer string `json:"referer"` Referer string `json:"referer"`
UserAgent string `json:"user_agent"` UserAgent string `json:"user_agent"`
// Request detail. RequestID is the X-Request-ID a trusted proxy sent, // Request detail. RequestID is the id rproxy made for the request, or for a
// or a new one, and is sent on to the app. ForwardedFor is the // request to discriminator's own endpoints a new one. ForwardedFor is the
// X-Forwarded-For header as received. ClientGroup is the netblock the // X-Forwarded-For header as received. ClientGroup is the netblock the
// client is counted as. ASN, ASName and Country are the client's AS // client is counted as. ASN, ASName and Country are the client's AS
// number, AS name and country, as looked up. // number, AS name and country, as looked up.
@@ -116,22 +112,15 @@ type Line struct {
ContentType string `json:"content_type,omitempty"` ContentType string `json:"content_type,omitempty"`
// ContentLength is the length of its body the request announced. // ContentLength is the length of its body the request announced.
ContentLength int64 `json:"content_length,omitempty"` ContentLength int64 `json:"content_length,omitempty"`
// RequestHeaders are the headers SWWAF_LOG_REQUEST_HEADERS names that // RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that
// the request carried, by name in lower case. // the request carried, by name in lower case.
RequestHeaders map[string]string `json:"request_headers,omitempty"` RequestHeaders map[string]string `json:"request_headers,omitempty"`
HasAuthorization bool `json:"has_authorization,omitempty"` HasAuthorization bool `json:"has_authorization,omitempty"`
HasCookie bool `json:"has_cookie,omitempty"` HasCookie bool `json:"has_cookie,omitempty"`
// Websocket is true when the connection was upgraded, as for a
// WebSocket.
Websocket bool `json:"websocket,omitempty"`
// Response detail, from the headers of the answer: the app's, as // Response detail, of discriminator's own answer. Aborted is true when
// passed on, or those of smallwebwaf's own. Aborted is true when the // rproxy, or the admin's tool, went away before the answer.
// client went away early.
ResponseContentType string `json:"response_content_type,omitempty"` ResponseContentType string `json:"response_content_type,omitempty"`
UpstreamStatus int `json:"upstream_status,omitempty"`
CacheControl string `json:"cache_control,omitempty"`
Location string `json:"location,omitempty"`
Aborted bool `json:"aborted,omitempty"` Aborted bool `json:"aborted,omitempty"`
// The decision. // The decision.
@@ -139,7 +128,8 @@ type Line struct {
// WouldAction is, in observe mode, the action enforce mode would have // WouldAction is, in observe mode, the action enforce mode would have
// taken with a request it would have refused: ActionDenied, // taken with a request it would have refused: ActionDenied,
// ActionBanned, ActionCountryDenied, ActionRateLimited, // ActionBanned, ActionCountryDenied, ActionRateLimited,
// ActionRuleBlocked or ActionWAFBlocked. // ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
// for the header limits.
WouldAction string `json:"would_action,omitempty"` WouldAction string `json:"would_action,omitempty"`
// LimitPercent and LimitPercentSetting are, for a request the rate // LimitPercent and LimitPercentSetting are, for a request the rate
// limits counted whose client a biased threshold gives a percentage of // limits counted whose client a biased threshold gives a percentage of
@@ -180,23 +170,60 @@ type Line struct {
// The timings, in milliseconds. DurationChecks is the time until the // The timings, in milliseconds. DurationChecks is the time until the
// checks were done, and DurationWAF the part of it the Core Rule Set // checks were done, and DurationWAF the part of it the Core Rule Set
// took. DurationUpstreamConnect, DurationUpstreamFirstByte and // took. DurationUpstreamTotal is what the report of an allowed request gives,
// DurationUpstreamTotal run from when the request was handed to the // from the hand-over to the app until the end of its answer. Each but
// app: until there was a connection to it, until the first byte of its // DurationTotal is nil for a request that did not get that far.
// answer arrived, and until the end. Each but DurationTotal is nil for
// a request that did not get that far.
DurationTotal float64 `json:"duration_total"` DurationTotal float64 `json:"duration_total"`
DurationChecks *float64 `json:"duration_checks,omitempty"` DurationChecks *float64 `json:"duration_checks,omitempty"`
DurationWAF *float64 `json:"duration_waf,omitempty"` DurationWAF *float64 `json:"duration_waf,omitempty"`
DurationUpstreamConnect *float64 `json:"duration_upstream_connect,omitempty"`
DurationUpstreamFirstByte *float64 `json:"duration_upstream_first_byte,omitempty"`
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"` DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
} }
// RProxyLine is rproxy's short line in the request log about one request. The
// field names, and their order, are those of docs/request-log.md. A field that
// does not apply to a request is left out of its line.
//
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
type RProxyLine struct {
Type string `json:"type"`
Time string `json:"time"`
RequestID string `json:"request_id"`
ClientIP string `json:"client_ip"`
Host string `json:"host"`
Method string `json:"method"`
Path string `json:"path"`
Query string `json:"query"`
Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"`
// DurationRequest runs in milliseconds from the request's headers being read
// until the app has been sent the whole request, or the answer has started
// if that is sooner. DurationResponse runs from then until the answer ends.
DurationRequest float64 `json:"duration_request"`
DurationResponse float64 `json:"duration_response"`
// Disposition is the valid disposition discriminator answered or, while
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
// when neither gave one.
Disposition string `json:"disposition,omitempty"`
DefaultDisposition bool `json:"default_disposition,omitempty"`
}
// Write writes line to w as one JSON line marked "type":"request". // Write writes line to w as one JSON line marked "type":"request".
func Write(w io.Writer, line *Line) error { func Write(w io.Writer, line *Line) error {
line.Type = "request" line.Type = "request"
return writeJSONLine(w, line)
}
// WriteRProxyLine writes line to w as one JSON line marked "type":"request".
func WriteRProxyLine(w io.Writer, line *RProxyLine) error {
line.Type = "request"
return writeJSONLine(w, line)
}
// writeJSONLine writes line to w as one JSON line.
func writeJSONLine(w io.Writer, line any) error {
encoded, err := json.Marshal(line) encoded, err := json.Marshal(line)
if err != nil { if err != nil {
return fmt.Errorf("encode the request log line: %w", err) return fmt.Errorf("encode the request log line: %w", err)
@@ -223,8 +250,8 @@ func Milliseconds(d time.Duration) float64 {
// NewProcessLogger returns the logger for the process's own messages: // NewProcessLogger returns the logger for the process's own messages:
// JSON lines on w, marked "type":"process", with the time in the same form // JSON lines on w, marked "type":"process", with the time in the same form
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance. // as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe; // It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
// the request lines Write writes are never held back. // the request lines Write writes are never held back.
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger { func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
+25 -3
View File
@@ -67,12 +67,34 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
} }
} }
func TestWriteRProxyLineWritesOneJSONLineWithOnlyItsFields(t *testing.T) {
t.Parallel()
var out bytes.Buffer
err := requestlog.WriteRProxyLine(&out, &requestlog.RProxyLine{
RequestID: "an-id",
Disposition: "allow",
})
if err != nil {
t.Fatalf("write: %v", err)
}
const want = `{"type":"request","time":"","request_id":"an-id","client_ip":"",` +
`"host":"","method":"","path":"","query":"","status":0,"request_bytes":0,` +
`"response_bytes":0,"duration_request":0,"duration_response":0,` +
`"disposition":"allow"}` + "\n"
if out.String() != want {
t.Errorf("wrote %s, want %s", out.String(), want)
}
}
func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) { func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
t.Parallel() t.Parallel()
var out bytes.Buffer var out bytes.Buffer
requestlog.NewProcessLogger(&out, "fsn1app1/gitea", slog.LevelInfo).Info("starting", requestlog.NewProcessLogger(&out, "fsn1app1/blog", slog.LevelInfo).Info("starting",
"version", "v1") "version", "v1")
var fields map[string]any var fields map[string]any
@@ -82,7 +104,7 @@ func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
t.Fatalf("decode %q: %v", out.String(), err) t.Fatalf("decode %q: %v", out.String(), err)
} }
if fields["type"] != "process" || fields["instance"] != "fsn1app1/gitea" || if fields["type"] != "process" || fields["instance"] != "fsn1app1/blog" ||
fields["msg"] != "starting" || fields["level"] != "INFO" || fields["msg"] != "starting" || fields["level"] != "INFO" ||
fields["version"] != "v1" { fields["version"] != "v1" {
t.Errorf("process line %v", fields) t.Errorf("process line %v", fields)
@@ -111,7 +133,7 @@ func TestProcessLoggerWritesTheMessagesAtItsLevelOrMoreSevere(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
processLog := requestlog.NewProcessLogger(&out, "fsn1app1/gitea", level) processLog := requestlog.NewProcessLogger(&out, "fsn1app1/blog", level)
for _, at := range levels { for _, at := range levels {
processLog.Log(t.Context(), at, "message") processLog.Log(t.Context(), at, "message")
} }
@@ -1,4 +1,4 @@
package proxy package rproxy
import ( import (
"bufio" "bufio"
@@ -7,39 +7,38 @@ import (
"net" "net"
"net/http" "net/http"
"sync/atomic" "sync/atomic"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// errResponseTooLarge ends an app's response body that is longer than // errResponseTooLarge ends an app's response body that is longer than
// SWWAF_RESPONSE_MAX_BYTES. // SWRP_MAX_RESPONSE_SIZE.
var errResponseTooLarge = errors.New( var errResponseTooLarge = errors.New(
"the response body is over SWWAF_RESPONSE_MAX_BYTES") "the response body is over SWRP_MAX_RESPONSE_SIZE")
// requestBody is the client's request body on its way to the app. The // requestBody is the visitor's request body. rproxy first reads up to the first
// transport reads it on a goroutine of its own. // 64 KiB of it into held, and then, on its way to the app, the transport reads
// it on a goroutine of its own.
type requestBody struct { type requestBody struct {
// body is the client's body, ending in an *http.MaxBytesError past // body is the visitor's body, ending in an *http.MaxBytesError past
// SWWAF_REQUEST_MAX_BYTES. // SWRP_MAX_REQUEST_SIZE.
body io.ReadCloser body io.ReadCloser
rq *request rq *request
// readByCoreRuleSet is what the Core Rule Set read of the body before // held is the part read before the request goes to the app, which Read gives
// the request went to the app, and Read gives first. // first.
readByCoreRuleSet []byte held []byte
// waiting is true while a Read waits for the client to send more. // waiting is true while a Read waits for the visitor to send more.
waiting atomic.Bool waiting atomic.Bool
// received is true once the client has sent the whole body. // received is true once the visitor has sent the whole body.
received atomic.Bool received atomic.Bool
// bytes is how much of the body has been read. // bytes is how much of the body has been read.
bytes atomic.Int64 bytes atomic.Int64
} }
// Read reads from the client's body, after what the Core Rule Set read of // Read reads from the visitor's body, after the part held, which has been
// it, which has been counted already. // counted already.
func (b *requestBody) Read(p []byte) (int, error) { func (b *requestBody) Read(p []byte) (int, error) {
if len(b.readByCoreRuleSet) > 0 { if len(b.held) > 0 {
n := copy(p, b.readByCoreRuleSet) n := copy(p, b.held)
b.readByCoreRuleSet = b.readByCoreRuleSet[n:] b.held = b.held[n:]
return n, nil return n, nil
} }
@@ -54,27 +53,22 @@ func (b *requestBody) Read(p []byte) (int, error) {
switch { switch {
case errors.Is(err, io.EOF): case errors.Is(err, io.EOF):
b.received.Store(true) b.received.Store(true)
b.rq.bodyReceived()
case errors.As(err, &tooLarge): case errors.As(err, &tooLarge):
b.rq.refuse(refusal{ b.rq.refuse(http.StatusRequestEntityTooLarge)
status: http.StatusRequestEntityTooLarge,
action: requestlog.ActionTooLarge,
limit: "SWWAF_REQUEST_MAX_BYTES",
})
} }
return n, err return n, err
} }
// Close closes the client's body. // Close closes the visitor's body.
func (b *requestBody) Close() error { func (b *requestBody) Close() error {
return b.body.Close() return b.body.Close()
} }
// responseBody is the app's response body on its way to the client. // responseBody is the app's response body on its way to the visitor.
type responseBody struct { type responseBody struct {
// body is the app's body, ending in an *http.MaxBytesError past // body is the app's body, ending in an *http.MaxBytesError past
// SWWAF_RESPONSE_MAX_BYTES. // SWRP_MAX_RESPONSE_SIZE.
body io.ReadCloser body io.ReadCloser
rq *request rq *request
} }
@@ -92,20 +86,13 @@ func (b *responseBody) Read(p []byte) (int, error) {
case errors.Is(err, io.EOF): case errors.Is(err, io.EOF):
b.rq.responseReceived() b.rq.responseReceived()
case errors.As(err, &tooLarge): case errors.As(err, &tooLarge):
b.rq.refuse(refusal{ b.rq.refuse(http.StatusBadGateway)
status: http.StatusBadGateway,
action: requestlog.ActionTooLarge,
limit: "SWWAF_RESPONSE_MAX_BYTES",
})
return n, errResponseTooLarge return n, errResponseTooLarge
case b.rq.in.Context().Err() == nil: case b.rq.in.Context().Err() == nil:
// The answer broke off, not because the client went away. If a // The answer broke off, not because the visitor went away. If a time
// timeout cut it, that refusal came first and is the one kept. // limit cut it, that refusal came first and is the one kept.
b.rq.refuse(refusal{ b.rq.refuse(http.StatusBadGateway)
status: http.StatusBadGateway,
action: requestlog.ActionUpstreamError,
})
} }
return n, err return n, err
@@ -117,15 +104,14 @@ func (b *responseBody) Close() error {
} }
// upgradedConn is the connection to the app once the app has switched // upgradedConn is the connection to the app once the app has switched
// protocols, as for a WebSocket. ReverseProxy writes to it what the client // protocols, as for a WebSocket. ReverseProxy writes to it what the visitor
// sends and reads from it what the app sends, on goroutines of its own, // sends and reads from it what the app sends, on goroutines of its own, until
// until the connection closes; it counts the bytes each way, for the byte // the connection closes; it counts the bytes each way, for the report.
// limits.
type upgradedConn struct { type upgradedConn struct {
io.ReadWriteCloser io.ReadWriteCloser
// fromApp is how many bytes the app has sent, and toApp how many the // fromApp is how many bytes the app has sent, and toApp how many the
// client has. // visitor has.
fromApp atomic.Int64 fromApp atomic.Int64
toApp atomic.Int64 toApp atomic.Int64
} }
@@ -138,7 +124,7 @@ func (c *upgradedConn) Read(p []byte) (int, error) {
return n, err return n, err
} }
// Write sends the app what the client sent. // Write sends the app what the visitor sent.
func (c *upgradedConn) Write(p []byte) (int, error) { func (c *upgradedConn) Write(p []byte) (int, error) {
n, err := c.ReadWriteCloser.Write(p) n, err := c.ReadWriteCloser.Write(p)
c.toApp.Add(int64(n)) c.toApp.Add(int64(n))
@@ -146,9 +132,9 @@ func (c *upgradedConn) Write(p []byte) (int, error) {
return n, err return n, err
} }
// CloseWrite tells the app that the client sends no more, while what the // CloseWrite tells the app that the visitor sends no more, while what the app
// app sends still passes. ReverseProxy calls it once the client has // sends still passes. ReverseProxy calls it once the visitor has stopped
// stopped sending, and closes the connection there if it is not supported. // sending, and closes the connection there if it is not supported.
func (c *upgradedConn) CloseWrite() error { func (c *upgradedConn) CloseWrite() error {
conn, ok := c.ReadWriteCloser.(interface{ CloseWrite() error }) conn, ok := c.ReadWriteCloser.(interface{ CloseWrite() error })
if !ok { if !ok {
@@ -158,8 +144,8 @@ func (c *upgradedConn) CloseWrite() error {
return conn.CloseWrite() return conn.CloseWrite()
} }
// limitBody returns body, cut off with an *http.MaxBytesError after // limitBody returns body, cut off with an *http.MaxBytesError after maxBytes, or
// maxBytes, or unchanged if maxBytes is zero, which is off. // unchanged if maxBytes is zero, which is off.
func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser { func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser {
if maxBytes == 0 { if maxBytes == 0 {
return body return body
@@ -169,19 +155,18 @@ func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser {
return http.MaxBytesReader(nil, body, maxBytes) return http.MaxBytesReader(nil, body, maxBytes)
} }
// responseWriter is the response to the client. It notes the status and // responseWriter is the response to the visitor. It notes the status and size
// size for the log line, and the first error writing to the client. // for the log line and the report.
type responseWriter struct { type responseWriter struct {
http.ResponseWriter http.ResponseWriter
// status is the final status sent, or zero before one is. // status is the final status sent, or zero before one is.
status int status int
bytes int64 bytes int64
err error
} }
// WriteHeader sends the status and headers. An informational 1xx status // WriteHeader sends the status and headers. An informational 1xx status is
// is passed on and the final status still comes later. // passed on and the final status still comes later.
func (w *responseWriter) WriteHeader(status int) { func (w *responseWriter) WriteHeader(status int) {
if status >= http.StatusOK && w.status == 0 { if status >= http.StatusOK && w.status == 0 {
w.status = status w.status = status
@@ -196,26 +181,20 @@ func (w *responseWriter) Write(p []byte) (int, error) {
w.status = http.StatusOK w.status = http.StatusOK
} }
n, err := w.ResponseWriter.Write(p) n, err := w.ResponseWriter.Write(p) //nolint:gosec // the answer, as it was given
w.bytes += int64(n) w.bytes += int64(n)
w.noteError(err)
return n, err return n, err
} }
// FlushError sends what has been written so far. // FlushError sends what has been written so far. http.ResponseController calls
// http.ResponseController calls it, as ReverseProxy does after each // it, as ReverseProxy does after each write.
// write.
func (w *responseWriter) FlushError() error { func (w *responseWriter) FlushError() error {
err := http.NewResponseController(w.ResponseWriter).Flush() return http.NewResponseController(w.ResponseWriter).Flush()
w.noteError(err)
return err
} }
// Hijack takes over the connection to the client, which ReverseProxy does // Hijack takes over the connection to the visitor, which ReverseProxy does only
// only to pass on the app's 101 Switching Protocols: that is then the // to pass on the app's 101 Switching Protocols: that is then the status sent.
// status sent.
func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack() conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack()
if err == nil { if err == nil {
@@ -225,15 +204,8 @@ func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return conn, buffered, err return conn, buffered, err
} }
// Unwrap lets http.ResponseController reach net/http's own // Unwrap lets http.ResponseController reach net/http's own ResponseWriter for
// ResponseWriter for what responseWriter does not do itself. // what responseWriter does not do itself.
func (w *responseWriter) Unwrap() http.ResponseWriter { func (w *responseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter return w.ResponseWriter
} }
// noteError keeps the first error writing to the client.
func (w *responseWriter) noteError(err error) {
if w.err == nil {
w.err = err
}
}
@@ -1,30 +1,40 @@
package proxy package rproxy
import ( import (
"crypto/rand"
"net/http" "net/http"
"net/netip" "net/netip"
"slices" "slices"
"strings" "strings"
) )
// peerAddress is the address of the request's TCP peer, normally traefik. // The headers rproxy sets on the requests it passes to the app. 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 visitor's own whatever
// their case; header names are not case-sensitive.
const (
requestIDHeader = "X-Request-ID"
asnHeader = "X-Client-Asn"
countryHeader = "X-Client-Country"
)
// peerAddress is the address of the request's TCP peer, normally traefik, without
// any IPv6 zone, which discriminator refuses.
func peerAddress(r *http.Request) netip.Addr { func peerAddress(r *http.Request) netip.Addr {
addrPort, err := netip.ParseAddrPort(r.RemoteAddr) addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil { if err != nil {
return netip.Addr{} return netip.Addr{}
} }
return addrPort.Addr().Unmap() return addrPort.Addr().Unmap().WithZone("")
} }
// clientAddress works out who the client is. A peer outside the trusted // clientAddress works out who the visitor is. A peer outside the trusted
// proxies is the client, and what it says in X-Forwarded-For is ignored. // proxies is the visitor, and what it says in X-Forwarded-For is ignored. For a
// For a peer inside them, X-Forwarded-For is read from the right, and the // peer inside them, X-Forwarded-For is read from the right, and the first
// first address outside them is the client; if every address in it is // address outside them is the visitor; if every address in it is inside, the
// inside, the leftmost is, and with no header, the peer. An entry that is // leftmost is, and with no header, the peer. An entry that is not an address
// not an address ends the reading, since nothing to its left can be // ends the reading, since nothing to its left can be believed. The visitor's
// believed. // address never has an IPv6 zone, which discriminator refuses.
func clientAddress( func clientAddress(
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix, peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
) netip.Addr { ) netip.Addr {
@@ -40,7 +50,7 @@ func clientAddress(
break break
} }
client = addr.Unmap() client = addr.Unmap().WithZone("")
if !isInside(client, trusted) { if !isInside(client, trusted) {
break break
} }
@@ -49,24 +59,8 @@ func clientAddress(
return client return client
} }
// requestIDHeader carries the request's id, from traefik and to the app. // scheme is how the visitor reached traefik, as a trusted proxy says in
const requestIDHeader = "X-Request-ID" // X-Forwarded-Proto, or otherwise http, the only scheme rproxy serves.
// requestID is the request's id: the one a trusted proxy sent, or a new
// random one. A peer outside the trusted proxies did not come through
// traefik, so the id it sends is its own claim, and is replaced.
func requestID(r *http.Request, peerTrusted bool) string {
id := r.Header.Get(requestIDHeader)
if !peerTrusted || id == "" {
id = rand.Text()
}
return id
}
// scheme is how the client reached traefik, as a trusted proxy says in
// X-Forwarded-Proto, or otherwise http, the only scheme smallwebwaf
// serves.
func scheme(r *http.Request, peerTrusted bool) string { func scheme(r *http.Request, peerTrusted bool) string {
proto := r.Header.Get("X-Forwarded-Proto") proto := r.Header.Get("X-Forwarded-Proto")
if !peerTrusted || proto == "" { if !peerTrusted || proto == "" {
@@ -76,19 +70,6 @@ func scheme(r *http.Request, peerTrusted bool) string {
return proto 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 SWWAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
addr = addr.Unmap()
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. // isInside reports whether addr is in one of the netblocks.
func isInside(addr netip.Addr, netblocks []netip.Prefix) bool { func isInside(addr netip.Addr, netblocks []netip.Prefix) bool {
return slices.ContainsFunc(netblocks, func(netblock netip.Prefix) bool { return slices.ContainsFunc(netblocks, func(netblock netip.Prefix) bool {
@@ -97,12 +78,11 @@ func isInside(addr netip.Addr, netblocks []netip.Prefix) bool {
} }
// setForwardedHeaders sets the headers in which the app learns about the // setForwardedHeaders sets the headers in which the app learns about the
// client, so that it sees what it would see from traefik directly. A // visitor, so that it sees what it would see from traefik directly. A trusted
// trusted proxy's forwarded headers pass on, with the proxy's own address // proxy's forwarded headers pass on, with the proxy's own address added to
// added to X-Forwarded-For. Those of any other peer are its own claims and // X-Forwarded-For. Those of any other peer are its own claims and are replaced:
// are replaced: X-Forwarded-For names the peer, X-Forwarded-Host the host // X-Forwarded-For names the peer, X-Forwarded-Host the host it asked for, and
// it asked for, and X-Forwarded-Proto plain http, which is how it reached // X-Forwarded-Proto plain http, which is how it reached rproxy.
// smallwebwaf.
func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) { func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) {
forwardedFor := peer.String() forwardedFor := peer.String()
@@ -125,9 +105,9 @@ func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) {
return return
} }
// ReverseProxy has removed Forwarded and the three set below; these // ReverseProxy has removed Forwarded and the three set below; these are the
// are the other headers in which traefik tells the app about the // other headers in which traefik tells the app about the visitor and its
// client and its request. // request.
for _, name := range []string{ for _, name := range []string{
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri", "X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert", "X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
+267
View File
@@ -0,0 +1,267 @@
package rproxy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"strings"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
)
// answerBodyMaxBytes is the most of the body of discriminator's answer that is read.
// It is far above any answer's.
const answerBodyMaxBytes = 1 << 20
// reportAnswerMaxBytes is the most of the answer to a report that is read,
// which has no body when discriminator takes the report.
const reportAnswerMaxBytes = 4 << 10
var (
errCannotPassOn = errors.New("the answer cannot be passed to the visitor")
errReportRefused = errors.New("the report was answered")
)
// discriminatorAnswer is what discriminator answered to a question.
type discriminatorAnswer struct {
status int
header http.Header
body []byte
}
// noteAnswer notes what discriminator answered. An answer without a valid disposition
// notes nothing, and no report is due for it: discriminator holds no decision to report
// on.
func (rq *request) noteAnswer(answer *discriminatorAnswer) {
disposition := answer.header.Get(discriminator.DispositionHeader)
switch disposition {
case discriminator.DispositionAllow, discriminator.DispositionDeny,
discriminator.DispositionClose:
rq.disposition = disposition
rq.answered = true
rq.asn = answer.header.Get(asnHeader)
rq.country = answer.header.Get(countryHeader)
}
}
// handleNoUsableAnswer deals with a request whose question ended in err, which
// leaves no answer to act on. ctx is the request's own context.
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
switch {
case ctx.Err() != nil:
// The visitor went away while discriminator was being asked. discriminator has not
// failed, and nothing is noted. This panic has Go's server close the
// connection without an answer, and log nothing; the deferred finish still
// runs.
panic(http.ErrAbortHandler)
case errors.Is(err, errCannotPassOn):
rq.h.processLog.Warn("discriminator's answer is dropped", "error", err.Error())
rq.answer(http.StatusBadGateway)
default:
// discriminator cannot be connected to, does not answer at all, or does not answer
// within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past
// SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
// whenever discriminator is slow. Making such requests hard to send is
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error())
rq.disposition = rq.h.config.DefaultDisposition
rq.byDefault = true
if rq.disposition == discriminator.DispositionAllow {
rq.passToApp(ctx)
} else {
rq.answer(http.StatusServiceUnavailable)
}
}
}
// ask sends discriminator the question, and returns its answer, which must have come
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
// away.
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
defer cancel()
question, err := rq.newQuestion(ctx)
if err != nil {
return nil, err
}
res, err := rq.h.discriminatorTransport.RoundTrip(question)
if err != nil {
return nil, fmt.Errorf("send the question: %w", err)
}
defer func() {
_ = res.Body.Close()
}()
// The body of a 101 is the connection itself, and the time limit does not end
// a read of that. Go's client also takes a status below 100, which Go's server
// panics when asked to send.
if res.StatusCode < http.StatusOK {
return nil, fmt.Errorf("%w: its status is %d", errCannotPassOn, res.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1))
if err != nil {
return nil, fmt.Errorf("read the answer: %w", err)
}
if len(body) > answerBodyMaxBytes {
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
}
return &discriminatorAnswer{res.StatusCode, res.Header, body}, nil
}
// canBeAsked reports whether Go's client can send discriminator r as the visitor sent
// it. It cannot send these as they came:
// - a target that does not start with /, or that starts with //;
// - an empty Host, for which it sends discriminator's own address;
// - a Host holding an IPv6 zone, which it removes;
// - more than one User-Agent, of which it sends the first;
// - an empty User-Agent, which it leaves out, the one case that still goes to
// discriminator.
func canBeAsked(r *http.Request) bool {
target, host := r.RequestURI, r.Host
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
return strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") &&
host != "" && !hostHasZone && len(r.Header.Values("User-Agent")) <= 1
}
// newQuestion returns the question about the request, as "The question" in
// SPEC.md gives it: the visitor's request as the visitor sent it, with the part
// of the body it holds and rproxy's headers.
func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
in := rq.in
var held []byte
if rq.body != nil {
held = rq.body.held
}
question, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
ctx, in.Method, rq.h.config.WAFBackend.String(), bytes.NewReader(held))
if err != nil {
return nil, fmt.Errorf("make the question: %w", err)
}
backend := question.URL
target := *in.URL
target.Scheme = backend.Scheme
target.Host = backend.Host
setPathAsSent(&target, in)
question.URL = &target
question.Host = in.Host
// The length NewRequestWithContext set is that of what is held. This line makes
// a body whose length the visitor did not announce go chunked, as "The question"
// in SPEC.md requires.
question.TransferEncoding = in.TransferEncoding
question.Header = in.Header.Clone()
question.Header.Set(discriminator.ClientHeader, rq.client.String())
question.Header.Set(discriminator.PeerHeader, rq.peer.String())
question.Header.Del(discriminator.ContentLengthHeader)
announced := in.Header.Get("Content-Length")
if announced != "" {
question.Header.Set(discriminator.ContentLengthHeader, announced)
}
question.Header.Set(requestIDHeader, rq.id)
question.Header.Set("X-Forwarded-Proto", scheme(in, rq.peerTrusted))
// Go's client adds a User-Agent of its own to a request without the key.
_, sent := question.Header["User-Agent"]
if !sent {
question.Header["User-Agent"] = nil
}
return question, nil
}
// answerWith gives the visitor discriminator's answer, less its disposition.
func (rq *request) answerWith(answer *discriminatorAnswer) {
rq.prepareOwnAnswer(rq.requestDeadline())
header := rq.out.Header()
maps.Copy(header, answer.header)
header.Del(discriminator.DispositionHeader)
// Go's server adds a Content-Type it guesses from the body to a response whose
// headers have no Content-Type key. The key with no value sends none.
if header.Get("Content-Type") == "" {
header["Content-Type"] = nil
}
rq.send(answer.status, answer.body)
}
// sendReport sends discriminator report on a goroutine of its own, so that the
// visitor does not wait for it. The report goes on after ctx, the request's own
// context, has ended.
func (h *handler) sendReport(ctx context.Context, report *discriminator.Report) {
h.reportsMu.Lock()
defer h.reportsMu.Unlock()
if h.reportsStopped {
return
}
h.reports.Go(func() {
err := h.postReport(context.WithoutCancel(ctx), report)
if err != nil {
h.processLog.Warn("a report is dropped", "error", err.Error())
}
})
}
// postReport posts report to discriminator, and waits for its answer for up to
// SWRP_TIMEOUT_MS.
func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error {
body, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("encode the report: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
defer cancel()
req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
ctx, http.MethodPost, h.reportURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("make the request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := h.discriminatorTransport.RoundTrip(req)
if err != nil {
return fmt.Errorf("send the report: %w", err)
}
// The body of a 101 is the connection itself, and the time limit does not end
// a read of that.
if res.StatusCode >= http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, reportAnswerMaxBytes))
}
_ = res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return fmt.Errorf("%w %s", errReportRefused, res.Status)
}
return nil
}
+831
View File
@@ -0,0 +1,831 @@
package rproxy_test
import (
"bufio"
"bytes"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
)
// post is the start of a POST whose headers end with what follows it.
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
// announcedLength is the header that announces a body of size bytes, and the
// end of the headers.
func announcedLength(size int) string {
return "Content-Length: " + strconv.Itoa(size) + "\r\n\r\n"
}
func TestServerHasTheFixedLimits(t *testing.T) {
t.Parallel()
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
if name == "SWRP_WAF_BACKEND" {
return nowhere, true
}
return "", false
})
if err != nil {
t.Fatalf("settings: %v", err)
}
server := rproxy.New(rproxy.Params{
Config: cfg,
RequestLog: io.Discard,
ProcessLog: requestlog.NewProcessLogger(io.Discard, "test", slog.LevelInfo),
})
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 TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, limit string
size, asked int
}{
{"with the limit within the first part", "1K", 1025, 1024},
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestSize: tc.limit,
})
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
bytes.NewReader(make([]byte, tc.size))))
if got.status != http.StatusRequestEntityTooLarge {
t.Errorf("answered %+v, want 413", got)
}
if asked := standIn.question(t); len(asked.body) != tc.asked {
t.Errorf("the question carries %d bytes, want %d", len(asked.body), tc.asked)
}
report := standIn.report(t)
if report.Status != http.StatusRequestEntityTooLarge {
t.Errorf("report %+v, want status 413", report)
}
})
}
}
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestSize: "1K",
})
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
bytes.NewReader(make([]byte, 1025))))
if got.status != http.StatusForbidden {
t.Errorf("answered %+v, want discriminator's 403", got)
}
}
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, limit string
size int
// mayReachApp is true when the part of the body the limit lets through
// goes on to the app before the limit is met. The app may still not have
// started on the request when it is cut off.
mayReachApp bool
}{
{"within the first part", "1K", 2 << 10, false},
{"past the first part", "128K", 129 << 10, true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var asked atomic.Bool
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
asked.Store(true)
greet(w, r)
})
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestSize: tc.limit,
})
conn := dial(t, addr)
send(t, conn, post+"Transfer-Encoding: chunked\r\n\r\n"+
strconv.FormatInt(int64(tc.size), 16)+"\r\n"+strings.Repeat("x", tc.size)+
"\r\n0\r\n\r\n")
if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge {
t.Errorf("status %d, want 413", status)
}
if asked.Load() && !tc.mayReachApp {
t.Error("the app was asked")
}
})
}
}
func TestAResponseOverItsLimit(t *testing.T) {
t.Parallel()
large := strings.Repeat("x", 2<<10)
t.Run("announced, is refused", func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
_, _ = io.WriteString(w, large)
})
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseSize: "1K",
})
got := get(t, addr, "/")
if got.status != http.StatusBadGateway {
t.Errorf("answered %d, want 502", got.status)
}
if report := standIn.report(t); report.Status != http.StatusBadGateway {
t.Errorf("report %+v, want status 502", report)
}
})
t.Run("not announced, is cut off", func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, large[:1])
_ = http.NewResponseController(w).Flush()
_, _ = io.WriteString(w, large[1:])
})
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseSize: "1K",
})
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
text, err := io.ReadAll(conn)
if err != nil || bytes.Contains(text, []byte(large)) {
t.Errorf("read %d bytes (%v), want the answer cut off", len(text), err)
}
report := standIn.report(t)
if report.Status != http.StatusOK || report.ResponseBytes > 1<<10 {
t.Errorf("report %+v, want status 200 and at most 1K of the answer", report)
}
})
}
func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.T) {
t.Parallel()
const announced = "2048"
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(contentLengthHeader, announced)
})
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseSize: "1K",
})
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
if got.status != http.StatusOK || got.header.Get(contentLengthHeader) != announced {
t.Errorf("answered %+v, want the app's 200 announcing %s bytes", got, announced)
}
}
func TestASettingOfOffSetsNoLimit(t *testing.T) {
t.Parallel()
for _, setting := range []string{
maxRequestSize, maxResponseSize, maxRequestTime, maxResponseTime,
} {
t.Run(setting, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
setting: "off",
})
// A body and an answer that a limit of zero would cut off, as it would the
// next answer, which rproxy gives itself.
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader("a body"))))
if got := get(t, addr, discriminator.HealthPath); got.status != http.StatusOK {
t.Errorf("answered %+v, want rproxy's own 200", got)
}
})
}
}
func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, rest string
}{
{"with its length announced", announcedLength(100) + "some of it"},
{"with no length announced", "Transfer-Encoding: chunked\r\n\r\na\r\nsome of it\r\n"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "500",
})
conn := dial(t, addr)
send(t, conn, post+tc.rest)
if status := readStatus(t, conn); status != http.StatusRequestTimeout {
t.Errorf("status %d, want 408", status)
}
line := out.requestLine(t)
if line["disposition"] != nil || line["request_bytes"] != 10.0 {
t.Errorf("line %v, want no disposition and 10 bytes", line)
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
}
standIn.wantNoReport(t)
})
}
}
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, post+announcedLength(100)+"some of it")
_ = conn.Close()
line := out.requestLine(t)
if line["status"] != 0.0 || line["disposition"] != nil {
t.Errorf("line %v, want no answer and no disposition", line)
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
}
}
func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, request string
stopsSending bool
}{
{
"a malformed chunk", post + "Transfer-Encoding: chunked\r\n\r\nnot a chunk\r\n",
false,
},
{"a visitor that stops sending", post + announcedLength(100) + "some of it", true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, tc.request)
if tc.stopsSending {
tcpConn, ok := conn.(*net.TCPConn)
if !ok {
t.Fatalf("the connection is a %T", conn)
}
err := tcpConn.CloseWrite()
if err != nil {
t.Fatalf("stop sending: %v", err)
}
}
text, err := io.ReadAll(conn)
if err != nil || len(text) != 0 {
t.Errorf("read %q (%v), want the connection closed without an answer",
text, err)
}
line := out.requestLine(t)
if line["status"] != 0.0 || line["disposition"] != nil {
t.Errorf("line %v, want no answer and no disposition", line)
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
}
})
}
}
// deadlineRecorder notes the time limits rproxy sets on reading the visitor's body
// and on sending its answer.
type deadlineRecorder struct {
*httptest.ResponseRecorder
readDeadline time.Time
writeDeadline time.Time
}
// SetReadDeadline notes deadline.
func (w *deadlineRecorder) SetReadDeadline(deadline time.Time) error {
w.readDeadline = deadline
return nil
}
// SetWriteDeadline notes deadline.
func (w *deadlineRecorder) SetWriteDeadline(deadline time.Time) error {
w.writeDeadline = deadline
return nil
}
func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
t.Parallel()
const (
limit = 5 * time.Minute
// announced is over what Go's server reads of a body left unread, so that
// the app's own server answers without waiting for the rest.
announced = 100 * questionBodyMaxBytes
)
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: strconv.FormatInt(limit.Milliseconds(), 10),
})
// The visitor sends the first part and a little more, and then nothing.
stalled, stop := io.Pipe()
t.Cleanup(func() { _ = stop.Close() })
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled))
req.ContentLength = announced
req.Header.Set(contentLengthHeader, strconv.Itoa(announced))
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
before := time.Now()
server.Handler.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status %d, want the app's 204", w.Code)
}
if w.readDeadline.Before(before.Add(limit)) ||
w.readDeadline.After(time.Now().Add(limit)) {
t.Errorf("the reading of the body ends at %s, want %s from the request",
w.readDeadline, limit)
}
}
func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
t.Parallel()
const limit = 5 * time.Minute
for _, tc := range []struct {
name, target string
status int
}{
{"discriminator's deny", "/", http.StatusForbidden},
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
app := startApp(t, greet)
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target,
http.NoBody)
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
before := time.Now()
server.Handler.ServeHTTP(w, req)
if w.Code != tc.status {
t.Errorf("status %d, want %d", w.Code, tc.status)
}
if w.writeDeadline.Before(before.Add(limit)) ||
w.writeDeadline.After(time.Now().Add(limit)) {
t.Errorf("the time limit on sending ends at %s, want %s from the answer",
w.writeDeadline, limit)
}
})
}
}
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "3000",
})
conn := dial(t, addr)
send(t, conn, post+announcedLength(2*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes+1))
if status := readStatus(t, conn); status != http.StatusRequestTimeout {
t.Errorf("status %d, want 408", status)
}
if asked := standIn.question(t); len(asked.body) != questionBodyMaxBytes {
t.Errorf("the question carries %d bytes", len(asked.body))
}
if report := standIn.report(t); report.Status != http.StatusRequestTimeout {
t.Errorf("report %+v, want status 408", report)
}
}
// 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)
}
stopped := make(chan struct{})
t.Cleanup(func() {
close(stopped)
_ = listener.Close()
})
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func() {
<-stopped
_ = conn.Close()
}()
}
}()
return "http://" + listener.Addr().String()
}
func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
t.Parallel()
// More than the connections hold while nobody reads, written a little at a
// time, so that rproxy soon waits for the app.
const (
bodySize = 64 << 20
writeSize = 32 << 10
)
standIn := startDiscriminator(t, allow)
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
map[string]string{maxRequestTime: "3000"})
conn := dial(t, addr)
send(t, conn, post+announcedLength(bodySize))
go func() {
part := make([]byte, writeSize)
for range bodySize / writeSize {
_, err := conn.Write(part)
if err != nil {
return
}
}
}()
if status := readStatus(t, conn); status != http.StatusGatewayTimeout {
t.Errorf("status %d, want 504", status)
}
}
func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
t.Parallel()
release := make(chan struct{})
standIn := startDiscriminator(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
<-release
})
t.Cleanup(func() { close(release) })
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: "300",
})
got := get(t, addr, "/")
if got.status != http.StatusGatewayTimeout {
t.Errorf("answered %d, want 504", got.status)
}
if report := standIn.report(t); report.Status != http.StatusGatewayTimeout {
t.Errorf("report %+v, want status 504", report)
}
}
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
t.Parallel()
const start = "the start"
release := make(chan struct{})
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, start)
_ = http.NewResponseController(w).Flush()
<-release
})
t.Cleanup(func() { close(release) })
// The time limit is long enough for a loaded host to pass on the start of the
// answer before it runs out.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: "3000",
})
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
// The body breaks off when rproxy closes the connection. If the connection
// stays open, dial's deadline ends this read with a timeout instead.
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
if res.StatusCode != http.StatusOK || string(body) != start ||
!errors.Is(err, io.ErrUnexpectedEOF) {
t.Errorf("answered %d %q (%v), want the app's 200 %q, then the connection closed",
res.StatusCode, body, err, start)
}
if report := standIn.report(t); report.Status != http.StatusOK {
t.Errorf("report %+v, want the status the visitor got", report)
}
}
func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
t.Parallel()
// More than the connections hold while nobody reads.
const (
bodySize = 64 << 20
writeSize = 32 << 10
)
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
part := make([]byte, writeSize)
for range bodySize / writeSize {
_, err := w.Write(part)
if err != nil {
return
}
}
})
// The time limit is long enough for a loaded host to start passing on the
// answer before it runs out.
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxResponseTime: "3000",
})
// The visitor asks, and never reads the answer.
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
line := out.requestLine(t)
responseBytes, _ := line["response_bytes"].(float64)
if line["status"] != float64(http.StatusOK) || responseBytes >= bodySize {
t.Errorf("line %v, want status 200 and the answer cut off", line)
}
report := standIn.report(t)
if report.Status != http.StatusOK || report.ResponseBytes >= bodySize {
t.Errorf("report %+v, want status 200 and the answer cut off", report)
}
}
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
got := get(t, addr, "/")
if got.status != http.StatusBadGateway {
t.Errorf("answered %d, want 502", got.status)
}
if report := standIn.report(t); report.Status != http.StatusBadGateway {
t.Errorf("report %+v, want status 502", report)
}
}
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, greeting)
})
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "3000",
})
// Far more announced than sent, so that Go's server answers for the app
// without waiting for the rest.
conn := dial(t, addr)
send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes))
if status := readStatus(t, conn); status != http.StatusOK {
t.Errorf("status %d, want the app's 200", status)
}
}
func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
t.Parallel()
working := make(chan struct{})
standIn := startDiscriminator(t, allow)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
close(working)
<-r.Context().Done()
})
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
select {
case <-working:
case <-time.After(waitLimit):
t.Fatal("the app was not asked")
}
tcpConn, ok := conn.(*net.TCPConn)
if !ok {
t.Fatalf("the connection is a %T", conn)
}
err := tcpConn.CloseWrite()
if err != nil {
t.Fatalf("stop sending: %v", err)
}
text, err := io.ReadAll(conn)
if err != nil || len(text) != 0 {
t.Errorf("read %q (%v), want the connection closed without an answer", text, err)
}
if line := out.requestLine(t); line["status"] != 0.0 {
t.Errorf("line %v, want status 0", line)
}
}
func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing.T) {
t.Parallel()
const answer = "the end of a long answer"
standIn := startDiscriminator(t, allow)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = http.NewResponseController(w).Flush()
time.Sleep(2100 * time.Millisecond)
_, _ = io.WriteString(w, answer)
})
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "2000",
})
got := visit(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")))
if got.status != http.StatusOK || got.body != answer {
t.Errorf("answered %d %q, want the app's whole answer", got.status, got.body)
}
}
func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, start string
status int
}{
{"a deny", post, http.StatusForbidden},
{
"the health check",
"GET " + discriminator.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
http.StatusOK,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
app := startApp(t, greet)
// The time limit is long enough for a loaded host to read the first part of
// the body before it runs out.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "3000",
})
// Go's server waits for a little of the body left before it sends an answer,
// and the time for sending the request ends that wait.
conn := dial(t, addr)
send(t, conn, tc.start+announcedLength(questionBodyMaxBytes+100)+
strings.Repeat("x", questionBodyMaxBytes))
if status := readStatus(t, conn); status != tc.status {
t.Errorf("status %d, want %d", status, tc.status)
}
})
}
}
+524
View File
@@ -0,0 +1,524 @@
package rproxy_test
import (
"bytes"
"crypto/rand"
"io"
"maps"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
)
// questionBodyMaxBytes is the most of a body a question carries.
const questionBodyMaxBytes = discriminator.QuestionBodyMaxBytes
// chunked is the encoding of a body whose length the visitor did not announce.
const chunked = "chunked"
// pathGoReencodes is a path Go's client would decode and escape again, %2F turned
// into / and | into %7C, unless told to send it as it is. A test sets it as the
// visitor's URL.Opaque, so that the visitor sends it unchanged too.
const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x"
req := newRequest(t, http.MethodDelete, addr, target, http.NoBody)
req.URL.Opaque = pathGoReencodes
req.Host = site
req.Header.Set("X-Test", "from the visitor")
req.Header.Set(forwardedForHeader, "198.51.100.1")
req.Header.Set(forwardedProtoHeader, "gopher")
req.Header.Set(requestIDHeader, "the-visitor's")
req.Header.Set(discriminator.ClientHeader, "198.51.100.2")
req.Header.Set(discriminator.PeerHeader, "198.51.100.3")
req.Header.Set(discriminator.ContentLengthHeader, "12345")
// Go's client then sends no User-Agent, so that one in the question would be
// Go's own.
req.Header["User-Agent"] = nil
wantGreeting(t, visit(t, req))
asked := standIn.question(t)
if asked.method != http.MethodDelete || asked.target != target ||
asked.host != site {
t.Errorf("asked about %s %s at %s", asked.method, asked.target, asked.host)
}
if asked.header.Get(requestIDHeader) == "the-visitor's" {
t.Error("the question carries the visitor's id")
}
// Exactly these: visit's connection has the visitor send Connection: close.
want := http.Header{}
want.Set("Connection", "close")
want.Set("X-Test", "from the visitor")
want.Set(forwardedForHeader, "198.51.100.1")
want.Set(forwardedProtoHeader, "http")
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
want.Set(discriminator.ClientHeader, localhost)
want.Set(discriminator.PeerHeader, localhost)
if !maps.EqualFunc(asked.header, want, slices.Equal) {
t.Errorf("the question's headers are %v, want %v", asked.header, want)
}
}
func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
t.Parallel()
const visitor = "203.0.113.9"
for _, tc := range []struct {
name, trusted, client, proto, forwarded string
}{
{
"from a trusted proxy", localhost + "/32", visitor, "https",
visitor + ", " + localhost,
},
{"from a peer not trusted", "10.0.0.0/8", localhost, "http", localhost},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
forwarded := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
forwarded <- r.Header.Clone()
greet(w, r)
})
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: tc.trusted,
})
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedForHeader, visitor)
req.Header.Set(forwardedProtoHeader, "https")
wantGreeting(t, visit(t, req))
asked := standIn.question(t)
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
asked.header.Get(discriminator.PeerHeader) != localhost ||
asked.header.Get(forwardedForHeader) != visitor ||
asked.header.Get(forwardedProtoHeader) != tc.proto {
t.Errorf("the question's headers are %v", asked.header)
}
if line := out.requestLine(t); line["client_ip"] != tc.client {
t.Errorf("line %v, want client_ip %s", line, tc.client)
}
if got := (<-forwarded).Get(forwardedForHeader); got != tc.forwarded {
t.Errorf("the app saw %s %q, want %q", forwardedForHeader, got, tc.forwarded)
}
})
}
}
func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
header []string
client string
}{
{
"the first address outside the trusted proxies, from the right",
[]string{"198.51.100.7, 203.0.113.9, 10.0.0.2"}, "203.0.113.9",
},
{
"the leftmost, when every address is a trusted proxy",
[]string{"10.0.0.5, 10.0.0.2"}, "10.0.0.5",
},
{"the peer, with no header", nil, localhost},
{
"the last address read, before an entry that is not an address",
[]string{"203.0.113.9, unknown, 10.0.0.2"}, "10.0.0.2",
},
{
"read as one list, with several header lines",
[]string{"2001:db8::7", "10.0.0.2"}, "2001:db8::7",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32,10.0.0.0/8",
})
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header[forwardedForHeader] = tc.header
wantGreeting(t, visit(t, req))
if got := standIn.question(t).header.Get(discriminator.ClientHeader); got !=
tc.client {
t.Errorf("the question names %s, want %s", got, tc.client)
}
})
}
}
// randomBytes returns n random bytes.
func randomBytes(n int) []byte {
body := make([]byte, n)
_, _ = rand.Read(body) // it never returns an error
return body
}
func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
t.Parallel()
for _, size := range []int{
1, questionBodyMaxBytes - 1, questionBodyMaxBytes, questionBodyMaxBytes + 1,
5 * questionBodyMaxBytes,
} {
for _, announced := range []bool{true, false} {
name := strconv.Itoa(size) + " bytes, length announced: " +
strconv.FormatBool(announced)
t.Run(name, func(t *testing.T) {
t.Parallel()
body := randomBytes(size)
standIn := startDiscriminator(t, allow)
received := make(chan []byte, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
whole, _ := io.ReadAll(r.Body)
received <- whole
greet(w, r)
})
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
req := newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(body))
if !announced {
// Go's client sends a body of unknown length in chunks.
req.ContentLength = -1
}
wantGreeting(t, visit(t, req))
asked := standIn.question(t)
want := body[:min(size, questionBodyMaxBytes)]
wantQuestionBody(t, asked, want, size, announced)
if !bytes.Equal(<-received, body) {
t.Error("the app did not get the whole body")
}
if report := standIn.report(t); report.RequestBytes != int64(size) {
t.Errorf("report %+v, of %d bytes", report, size)
}
})
}
}
}
// wantQuestionBody checks the body a question carries, want, and how it gives
// the length of the visitor's body, of size bytes.
func wantQuestionBody(
t *testing.T, asked question, want []byte, size int, announced bool,
) {
t.Helper()
if !bytes.Equal(asked.body, want) {
t.Errorf("the question carries %d bytes, want the first %d", len(asked.body),
len(want))
}
values := asked.header.Values(discriminator.ContentLengthHeader)
if announced {
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
asked.contentLength != int64(len(want)) {
t.Errorf("announced %v and length %d, want %d and %d", values,
asked.contentLength, size, len(want))
}
return
}
if len(values) != 0 || !slices.Equal(asked.transferEncoding, []string{chunked}) {
t.Errorf("announced %v and encoded %v, want none and chunked", values,
asked.transferEncoding)
}
}
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
// Far more announced than sent. Go's server does not wait for that much, so
// the answer comes at once.
conn := dial(t, addr)
send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes))
if status := readStatus(t, conn); status != http.StatusForbidden {
t.Errorf("status %d, want discriminator's 403", status)
}
if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
t.Errorf("report %+v, want the first part's bytes", report)
}
}
func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, framing, body string
encoding []string
}{
{
chunked, "Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n", "body",
[]string{chunked},
},
{
"chunked and empty", "Transfer-Encoding: chunked\r\n\r\n0\r\n\r\n", "",
[]string{chunked},
},
{"with its length", announcedLength(4) + "body", "body", nil},
} {
for _, method := range []string{
http.MethodGet, http.MethodDelete, http.MethodPost,
} {
t.Run(method+" "+tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
conn := dial(t, addr)
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
if status := readStatus(t, conn); status != http.StatusForbidden {
t.Errorf("status %d, want discriminator's 403", status)
}
asked := standIn.question(t)
if string(asked.body) != tc.body ||
!slices.Equal(asked.transferEncoding, tc.encoding) {
t.Errorf("the question carries %q, encoded %v, want %q, encoded %v",
asked.body, asked.transferEncoding, tc.body, tc.encoding)
}
})
}
}
}
func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
t.Parallel()
for _, method := range []string{http.MethodTrace, "PROPFIND", "LOCK", "BREW"} {
t.Run(method, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
if got.status != http.StatusMethodNotAllowed ||
got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY" {
t.Errorf("answered %+v, want 405 naming the methods passed on", got)
}
line := out.requestLine(t)
if line["status"] != float64(http.StatusMethodNotAllowed) ||
line["disposition"] != nil || line["default_disposition"] != nil {
t.Errorf("line %v, want status 405 and no disposition", line)
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
}
standIn.wantNoReport(t)
})
}
}
func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
t.Parallel()
for _, method := range []string{
http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions,
http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY",
} {
t.Run(method, func(t *testing.T) {
t.Parallel()
reached := make(chan string, 1)
standIn := startDiscriminator(t, allow)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
reached <- r.Method
})
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
if got.status != http.StatusOK {
t.Errorf("answered %+v, want the app's 200", got)
}
if asked := standIn.question(t); asked.method != method {
t.Errorf("discriminator was asked about %s", asked.method)
}
if atApp := <-reached; atApp != method {
t.Errorf("the app was asked %s", atApp)
}
})
}
}
func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
t.Parallel()
const host = "Host: site.example\r\n\r\n"
for name, request := range map[string]string{
"a target of a scheme": "GET x:admin HTTP/1.1\r\n" + host,
"a target of a URL": "GET http://site.example/ HTTP/1.1\r\n" + host,
"a host Go will not send": "GET http://a<b/ HTTP/1.1\r\n" + host,
"a target of a host": "GET site.example:443 HTTP/1.1\r\n" + host,
"a target of *": "GET * HTTP/1.1\r\n" + host,
"a target of //": "GET //x/y HTTP/1.1\r\n" + host,
"a target of // escaped": "GET //x%2Fadmin| HTTP/1.1\r\n" + host,
"an empty Host": "GET / HTTP/1.1\r\nHost:\r\n\r\n",
"HTTP/1.0 with no Host": "GET / HTTP/1.0\r\n\r\n",
"a Host with a zone": "GET / HTTP/1.1\r\nHost: [fe80::1%25eth0]:80\r\n\r\n",
"two User-Agents": "GET / HTTP/1.1\r\nUser-Agent: a\r\nUser-Agent: b\r\n" +
host,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, request)
if status := readStatus(t, conn); status != http.StatusBadRequest {
t.Errorf("status %d, want 400", status)
}
line := out.requestLine(t)
if line["status"] != float64(http.StatusBadRequest) ||
line["disposition"] != nil || line["default_disposition"] != nil {
t.Errorf("line %v, want status 400 and no disposition", line)
}
if len(standIn.questions) != 0 {
t.Error("discriminator was asked")
}
standIn.wantNoReport(t)
})
}
}
func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) {
t.Parallel()
const host = "a%b"
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: "+host+"\r\n\r\n")
if status := readStatus(t, conn); status != http.StatusOK {
t.Errorf("status %d, want the app's 200", status)
}
if asked := standIn.question(t); asked.host != host {
t.Errorf("asked about the host %q, want %q", asked.host, host)
}
}
func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
t.Parallel()
const want = "http"
standIn := startDiscriminator(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32",
})
wantGreeting(t, get(t, addr, "/"))
asked := standIn.question(t)
if got := asked.header.Get(forwardedProtoHeader); got != want {
t.Errorf("the question's %s is %q, want %s", forwardedProtoHeader, got, want)
}
}
func TestAQuestionNamesNoZone(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, remoteAddr, forwardedFor, client, peer string
}{
{"from a link-local peer", "[fe80::1%eth0]:1234", "", "fe80::1", "fe80::1"},
{
"from X-Forwarded-For through a trusted peer", localhost + ":1234",
"2001:db8::1%eth0", "2001:db8::1", localhost,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startDiscriminator(t, deny)
app := startApp(t, greet)
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32",
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req.RemoteAddr = tc.remoteAddr
req.Header.Set(forwardedForHeader, tc.forwardedFor)
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
asked := standIn.question(t)
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
asked.header.Get(discriminator.PeerHeader) != tc.peer {
t.Errorf("the question's headers are %v", asked.header)
}
if line := out.requestLine(t); line["client_ip"] != tc.client {
t.Errorf("line %v, want client_ip %s", line, tc.client)
}
})
}
}
+593
View File
@@ -0,0 +1,593 @@
package rproxy
import (
"context"
"crypto/rand"
"errors"
"io"
"net/http"
"net/http/httptrace"
"net/http/httputil"
"net/netip"
"net/url"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"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
// reportStatusMax is the highest status discriminator takes in a report. An app can
// send a higher one, up to 999, and the visitor gets that as it is.
const reportStatusMax = 599
// plainText is the Content-Type of the answers rproxy gives itself.
const plainText = "text/plain; charset=utf-8"
// request is one request on its way through rproxy, from the moment its headers
// have been read to its log line and report.
type request struct {
h *handler
in *http.Request
// rc sets the deadlines of the connection to the visitor.
rc *http.ResponseController
out *responseWriter
body *requestBody // nil for a request without a body
// id is the fresh id made for the request when it arrived.
id string
client netip.Addr
peer netip.Addr
peerTrusted bool
start time.Time
// disposition is what is done with the request: discriminator's valid disposition,
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
disposition string
// answered is true when discriminator gave disposition, which makes a report due,
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
answered bool
byDefault bool
// asn and country are discriminator's X-Client-ASN and X-Client-Country, for the
// app.
asn, country string
// upstreamStart is when the request was handed to the app.
upstreamStart time.Time
// cancel ends the request to the app.
cancel context.CancelFunc
// refused is the status of the first refusal, from whichever goroutine meets
// it: what the visitor is answered if the response has not started yet.
refused atomic.Int64
// 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 time limits 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, neither time limit acts any more.
mu sync.Mutex
timersStopped bool
requestTimer *time.Timer
responseTimer *time.Timer
// requestEnd is when the app had been sent the whole request, or the answer
// started, if that was sooner.
requestEnd time.Time
// answerStarted is when the first byte of the app's answer arrived.
answerStarted time.Time
}
// newRequest starts handling r: it notes the time, works out the visitor from
// the TCP peer and X-Forwarded-For, and makes the request's id.
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
peer := peerAddress(r)
trusted := h.config.TrustedProxies
rq := &request{
h: h,
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
id: rand.Text(),
client: clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted),
peer: peer,
peerTrusted: isInside(peer, trusted),
start: time.Now(),
}
if r.Body != http.NoBody {
rq.body = &requestBody{body: limitBody(r.Body, h.config.MaxRequestSize), rq: rq}
}
return rq
}
// answerHealth answers the health check.
func (rq *request) answerHealth() {
rq.prepareOwnAnswer(rq.requestDeadline())
rq.out.Header().Set("Content-Type", plainText)
rq.send(http.StatusOK, []byte("ok\n"))
}
// readFirstPartOfBody reads up to the first 64 KiB of the body into memory,
// within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
// visitor too slow to send it is answered 408. For a malformed chunk, or a
// visitor that went away or stopped its sending side, the connection is closed
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
// read, so that discriminator is asked first; passToApp then refuses it.
func (rq *request) readFirstPartOfBody() bool {
if rq.body == nil {
return true
}
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
held, err := io.ReadAll(io.LimitReader(rq.body, discriminator.QuestionBodyMaxBytes))
// From here on, the request timer ends a read of the rest of the body, so that
// the answer can name the side rproxy was waiting on.
_ = rq.rc.SetReadDeadline(time.Time{})
rq.body.held = held
switch {
case err == nil || rq.refused.Load() != 0:
return true
case errors.Is(err, os.ErrDeadlineExceeded):
rq.answer(http.StatusRequestTimeout)
return false
default:
// This panic has Go's server close the connection without an answer, and
// log nothing; the deferred finish still runs.
panic(http.ErrAbortHandler)
}
}
// setPathAsSent has out, the URL of a request made from in, send the path as the
// visitor sent it. Go 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.
func setPathAsSent(out *url.URL, in *http.Request) {
out.Opaque = rules.Path(in)
}
// forward passes the request to the app, the part of the body held and then the
// rest, 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{
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.startRequestTimer()
rq.upstreamStart = time.Now()
reverseProxy.ServeHTTP(rq.out, out)
}
// rewrite makes the request the app receives: the visitor's request, unchanged,
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
// discriminator's.
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 visitor sent it.
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
setPathAsSent(pr.Out.URL, pr.In)
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
pr.Out.Header.Set(requestIDHeader, rq.id)
pr.Out.Header.Del(asnHeader)
pr.Out.Header.Del(countryHeader)
if rq.asn != "" {
pr.Out.Header.Set(asnHeader, rq.asn)
}
if rq.country != "" {
pr.Out.Header.Set(countryHeader, rq.country)
}
}
// modifyResponse looks at the app's answer before ReverseProxy passes it on.
func (rq *request) modifyResponse(res *http.Response) error {
if res.StatusCode == http.StatusSwitchingProtocols {
// An upgraded connection, such as a WebSocket, is not cut by the time
// limits. 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 visitor's, so the status
// is noted only once it has taken over the connection, by rq.out's
// Hijack.
rq.stopTimers()
conn, ok := res.Body.(io.ReadWriteCloser)
if ok {
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
res.Body = rq.upgraded
}
return nil
}
maxBytes := rq.h.config.MaxResponseSize
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
rq.refuse(http.StatusBadGateway)
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.setResponseDeadline()
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()
}
status := int(rq.refused.Load())
if status == 0 {
if rq.in.Context().Err() != nil {
// The visitor went away or stopped its sending side. This panic has Go's
// server close the connection without an answer, and log nothing; the
// deferred finish still runs.
panic(http.ErrAbortHandler)
}
rq.h.processLog.Warn("request to the app failed", "error", err.Error())
status = http.StatusBadGateway
}
rq.answer(status)
}
// answer sends rproxy's own answer, unless the response has already started.
func (rq *request) answer(status int) {
rq.refused.CompareAndSwap(0, int64(status))
if rq.out.status != 0 {
return // too late to answer: the connection can only be cut
}
// A visitor 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.requestDeadline()
if status == http.StatusRequestTimeout {
deadline = time.Now()
}
rq.prepareOwnAnswer(deadline)
header := rq.out.Header()
header.Set("Content-Type", plainText)
header.Set("X-Content-Type-Options", "nosniff")
rq.send(status, []byte(http.StatusText(status)+"\n"))
}
// send gives the visitor an answer rproxy holds whole: status, the headers set so
// far and body. Go's server would otherwise send so short an answer only once the
// handler has returned, after finish has written the log line and sent the report.
// Without Content-Length, the flush would send the body in chunks, the last of
// which would still wait for the handler. A HEAD's answer has no body, so any
// Content-Length it has stays as discriminator gave it.
func (rq *request) send(status int, body []byte) {
if rq.in.Method != http.MethodHead {
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
}
rq.out.WriteHeader(status)
_, _ = rq.out.Write(body)
_ = rq.rc.Flush()
}
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
// reading of a body that has not arrived whole ends at deadline, and the answer
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
// used that time up first.
func (rq *request) prepareOwnAnswer(deadline time.Time) {
rq.stopReadingBody(deadline)
rq.endRequest()
limit := rq.h.config.MaxResponseTime
if limit > 0 {
_ = rq.rc.SetWriteDeadline(time.Now().Add(limit))
}
}
// refuse records status, unless an earlier refusal was, and ends the request to
// the app, if one was made.
func (rq *request) refuse(status int) {
rq.refused.CompareAndSwap(0, int64(status))
if rq.cancel != nil {
rq.cancel()
}
}
// finish ends the request's time limits, writes its log line and, if discriminator
// answered the question, sends the report. ctx is the request's own context.
func (rq *request) finish(ctx context.Context) {
rq.stopTimers()
if rq.refused.Load() == 0 {
rq.stopReadingBody(rq.requestDeadline())
}
requestEnd := rq.endRequest()
end := time.Now()
requestBytes, responseBytes := rq.bytesEachWay()
err := requestlog.WriteRProxyLine(rq.h.requestLog, &requestlog.RProxyLine{
Time: requestlog.FormatTime(rq.start),
RequestID: rq.id,
ClientIP: rq.client.String(),
Host: rq.in.Host,
Method: rq.in.Method,
Path: rules.Path(rq.in),
Query: rq.in.URL.RawQuery,
Status: rq.out.status,
RequestBytes: requestBytes,
ResponseBytes: responseBytes,
DurationRequest: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
DurationResponse: requestlog.Milliseconds(end.Sub(requestEnd)),
Disposition: rq.disposition,
DefaultDisposition: rq.byDefault,
})
if err != nil {
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
}
if !rq.answered {
return
}
report := &discriminator.Report{
RequestID: rq.id,
Status: min(rq.out.status, reportStatusMax),
RequestBytes: requestBytes,
ResponseBytes: responseBytes,
DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)),
}
if !rq.upstreamStart.IsZero() {
report.DurationUpstreamTotal = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
}
rq.h.sendReport(ctx, report)
}
// bytesEachWay returns the body bytes of the request and of the answer. For an
// upgraded connection, such as a WebSocket, which has closed by then, what it
// carried from the visitor counts with the request's and what it carried from the
// app with the answer's.
func (rq *request) bytesEachWay() (int64, int64) {
var requestBytes int64
if rq.body != nil {
requestBytes = rq.body.bytes.Load()
}
responseBytes := rq.out.bytes
if rq.upgraded != nil {
requestBytes += rq.upgraded.toApp.Load()
responseBytes += rq.upgraded.fromApp.Load()
}
return requestBytes, responseBytes
}
// requestDeadline is when the visitor must have sent its whole request, or zero
// when SWRP_MAX_REQUEST_TIME_MS is off.
func (rq *request) requestDeadline() time.Time {
limit := rq.h.config.MaxRequestTime
if limit == 0 {
return time.Time{}
}
return rq.start.Add(limit)
}
// stopReadingBody ends, at deadline, the reading of a visitor's 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)
}
// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the
// request's headers being read until the app has been sent the whole request.
func (rq *request) startRequestTimer() {
deadline := rq.requestDeadline()
if deadline.IsZero() {
return
}
rq.mu.Lock()
defer rq.mu.Unlock()
rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
}
// requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the
// request is still on its way to the app. The answer names the side rproxy was
// waiting on at that moment: 408 when it was waiting for the visitor 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() {
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 visitor'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 visitor. That read ends now, before refuse:
// should rproxy still answer itself, answer then sets the deadline it
// gives its own answers.
if !rq.answerStarted.IsZero() {
rq.stopReadingBody(time.Now())
}
rq.refuse(http.StatusGatewayTimeout)
return
}
rq.refuse(http.StatusRequestTimeout)
// The transport gives up on the app only once its Read of the visitor'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())
}
// 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:
// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts.
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
if info.Err != nil {
return // the transport gives up, or tries again
}
requestEnd := rq.endRequest()
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.timersStopped {
return
}
stopTimer(rq.requestTimer)
limit := rq.h.config.MaxResponseTime
if limit > 0 {
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
rq.responseTimedOut)
}
}
// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS 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(http.StatusGatewayTimeout)
}
}
// responseReceived is called once the app has sent its whole answer. An app may
// answer before the visitor has sent its whole body, as with a 404 it sends
// without reading it. The time limits then run on: until the transport's read of
// the body returns, Go's server cannot send the answer, and
// SWRP_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops
// sending.
func (rq *request) responseReceived() {
if rq.body != nil && !rq.body.received.Load() {
return
}
rq.stopTimers()
}
// setResponseDeadline sets SWRP_MAX_RESPONSE_TIME_MS on the connection to the
// visitor: the app's answer must reach the visitor within it of the end of the
// request.
func (rq *request) setResponseDeadline() {
requestEnd := rq.endRequest()
limit := rq.h.config.MaxResponseTime
if limit > 0 {
_ = rq.rc.SetWriteDeadline(requestEnd.Add(limit))
}
}
// endRequest notes now as the end of the request, unless an end is noted, and
// returns the end noted.
func (rq *request) endRequest() time.Time {
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.requestEnd.IsZero() {
rq.requestEnd = time.Now()
}
return rq.requestEnd
}
// stopTimers stops the request's time limits 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.requestTimer)
stopTimer(rq.responseTimer)
}
// stopTimer stops t, which is nil when its time limit is off.
func stopTimer(t *time.Timer) {
if t != nil {
t.Stop()
}
}
+228
View File
@@ -0,0 +1,228 @@
// Package rproxy is the reverse proxy in front of one app that asks discriminator
// about each request. It passes a request discriminator allows to the app and the
// app's answer back, within the size and time limits. Any other answer from
// discriminator goes to the visitor as discriminator gave it. SWRP_DEFAULT_DISPOSITION
// decides only when discriminator cannot be connected to, does not answer at all, or
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
// method it does not pass on, or one it cannot put to discriminator as the visitor
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
// SPEC.md says what it does.
package rproxy
import (
"context"
"io"
"log"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
)
// How rproxy keeps connections to the app and to discriminator open between
// requests.
const (
idleConns = 100
idleConnTimeout = 90 * time.Second
)
// The limits on a connection from a visitor that have no setting.
const (
// requestHeaderMaxBytes is the largest request line and headers a visitor
// may send.
requestHeaderMaxBytes = 32 << 10
// goServerExtraHeaderBytes is how far past MaxHeaderBytes Go's server reads
// before it refuses.
goServerExtraHeaderBytes = 4 << 10
// idleTimeout is how long a kept-open connection may wait for its next
// request.
idleTimeout = 120 * time.Second
)
// adminPrefix starts the path of every request for sww itself, which
// never reaches the app.
const adminPrefix = "/_sww/"
// allowedMethods lists the methods rproxy passes on, as an Allow header gives
// them. It answers any other method 405 itself.
const allowedMethods = "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY"
// Params are what New needs.
type Params struct {
Config *config.RProxy
// RequestLog receives one short JSON line per request.
RequestLog io.Writer
// ProcessLog receives the process's own messages.
ProcessLog *slog.Logger
}
// Server is the server `sww rproxy` runs.
type Server struct {
*http.Server
h *handler
}
// New returns the server `sww rproxy` runs. Go's server itself refuses
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
// the headers arrive. The handler applies the time and size limits from then
// on.
func New(params Params) *Server {
backend := params.Config.WAFBackend
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
h := &handler{
config: params.Config,
requestLog: params.RequestLog,
processLog: params.ProcessLog,
errorLog: errorLog,
transport: newTransport(),
discriminatorTransport: newDiscriminatorTransport(),
reportURL: (&url.URL{
Scheme: backend.Scheme, Host: backend.Host, Path: discriminator.ReportPath,
}).String(),
}
return &Server{
Server: &http.Server{
Addr: params.Config.ListenAddr,
Handler: h,
ErrorLog: errorLog,
ReadHeaderTimeout: params.Config.MaxRequestTime,
IdleTimeout: idleTimeout,
MaxHeaderBytes: requestHeaderMaxBytes - goServerExtraHeaderBytes,
},
h: h,
}
}
// WaitForReports returns once the reports being sent have gone or failed,
// which SWRP_TIMEOUT_MS bounds. Any report after this call is dropped: that of
// a WebSocket that closes while rproxy stops, for one.
func (s *Server) WaitForReports() {
s.h.reportsMu.Lock()
s.h.reportsStopped = true
s.h.reportsMu.Unlock()
s.h.reports.Wait()
}
// handler is the proxy. It holds what every request shares; what belongs to
// one request is in a request.
type handler struct {
config *config.RProxy
requestLog io.Writer
processLog *slog.Logger
errorLog *log.Logger
// transport carries requests to the app, and discriminatorTransport questions and
// reports to discriminator.
transport http.RoundTripper
discriminatorTransport http.RoundTripper
// reportURL is where the reports go.
reportURL string
// reports waits for the reports being sent. reportsMu guards starting one
// against reportsStopped, which is set once nothing is to wait for another.
reports sync.WaitGroup
reportsMu sync.Mutex
reportsStopped bool
}
// newTransport returns what carries requests to the app. It never goes through
// a proxy named in the environment, and leaves answers compressed or not as they
// were sent.
func newTransport() *http.Transport {
return &http.Transport{
MaxIdleConns: idleConns,
MaxIdleConnsPerHost: idleConns,
IdleConnTimeout: idleConnTimeout,
DisableCompression: true,
}
}
// newDiscriminatorTransport returns what carries questions and reports to
// discriminator. It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send
// a visitor's Upgrade header, or a Connection header other than close or
// keep-alive, and that would read as discriminator giving no answer.
func newDiscriminatorTransport() *http.Transport {
transport := newTransport()
transport.Protocols = new(http.Protocols)
transport.Protocols.SetHTTP1(true)
return transport
}
// ServeHTTP handles one request: it answers the health check itself, refuses a
// request with a method it does not pass on or one it cannot put to discriminator as
// the visitor sent it, and for any other request reads the first part of the
// body, asks discriminator, and does what the answer says. Once the answer has ended,
// it writes the request's log line and sends its report.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r)
defer rq.finish(r.Context())
if r.Method == http.MethodGet && r.URL.Path == discriminator.HealthPath {
rq.answerHealth()
return
}
if !slices.Contains(strings.Split(allowedMethods, ", "), r.Method) {
rq.out.Header().Set("Allow", allowedMethods)
rq.answer(http.StatusMethodNotAllowed)
return
}
if !canBeAsked(r) {
rq.answer(http.StatusBadRequest)
return
}
if !rq.readFirstPartOfBody() {
return
}
answer, err := rq.ask(r.Context())
if err != nil {
rq.handleNoUsableAnswer(r.Context(), err)
return
}
rq.noteAnswer(answer)
switch rq.disposition {
case discriminator.DispositionAllow:
rq.passToApp(r.Context())
case discriminator.DispositionClose:
// This panic has Go's server close the connection without an answer,
// and log nothing; the deferred finish still runs.
panic(http.ErrAbortHandler)
default: // a deny, an error, or an answer without a valid disposition
rq.answerWith(answer)
}
}
// passToApp passes the request on to the app, unless a limit or its path keeps
// it from the app. discriminator answers a path under adminPrefix itself, so such a
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
// request's own context.
func (rq *request) passToApp(ctx context.Context) {
maxBytes := rq.h.config.MaxRequestSize
switch {
case rq.refused.Load() != 0 || (maxBytes > 0 && rq.in.ContentLength > maxBytes):
rq.answer(http.StatusRequestEntityTooLarge)
case strings.HasPrefix(rq.in.URL.Path, adminPrefix):
rq.answer(http.StatusNotFound)
default:
rq.forward(ctx)
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more